Merge pull request #3 from taigrr/cd/tests-deps-readme

test(app): expand test coverage, update deps, improve README
This commit is contained in:
2026-04-12 10:20:12 +02:00
committed by GitHub
7 changed files with 803 additions and 41 deletions

View File

@@ -1,28 +1,113 @@
# TeaQLite
A colorful-but-minimal terminal user interface for browsing SQLite databases built with Bubble Tea.
A colorful-but-minimal terminal user interface for browsing SQLite databases built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).
## Features
- **Table Browser**: Browse all tables in your SQLite database with pagination
- **Search Functionality**: Search tables by name using `/` key
- **Fuzzy Search**: Search tables by name using `/` key with ranked results
- **Data Viewer**: View table data with pagination and row highlighting
- **Row-Level Navigation**: Navigate through data rows with cursor highlighting
- **Data Search**: Search within table data using `/` key
- **Row Detail Modal**: View individual rows in a 2-column format (Column | Value)
- **Cell Editing**: Edit individual cell values with live database updates
- **SQL Query Interface**: Execute custom SQL queries with parameter support
- **Vim-style Navigation**: `j`/`k` movement, `gg`/`G` jumps, and more
- **Responsive Design**: Adapts to terminal size and fits content to screen
- **Navigation**: Intuitive keyboard navigation throughout all modes
## Install
```bash
go install github.com/taigrr/teaqlite@latest
```
Or build from source:
```bash
git clone https://github.com/taigrr/teaqlite.git
cd teaqlite
go build -o teaqlite .
```
## Usage
```bash
go run main.go <database.db>
teaqlite <database.db>
```
Example with the included sample database:
```bash
go run main.go sample.db
teaqlite sample.db
```
## Keybindings
### Global
| Key | Action |
| -------- | ----------- |
| `ctrl+c` | Quit |
| `ctrl+z` | Suspend |
| `ctrl+g` | Toggle help |
### Table List
| Key | Action |
| --------- | -------------- |
| `↑`/`k` | Move up |
| `↓`/`j` | Move down |
| `←`/`h` | Previous page |
| `→`/`l` | Next page |
| `enter` | Open table |
| `/` | Search tables |
| `gg` | Jump to start |
| `G` | Jump to end |
| `s` | SQL query mode |
| `r` | Refresh |
| `esc` | Clear filter |
### Table Data
| Key | Action |
| --------- | --------------- |
| `↑`/`k` | Move up |
| `↓`/`j` | Move down |
| `←`/`h` | Previous page |
| `→`/`l` | Next page |
| `enter` | View row detail |
| `/` | Search data |
| `gg` | Jump to start |
| `G` | Jump to end |
| `q` | Back to tables |
| `s` | SQL query mode |
| `r` | Refresh |
### Row Detail
| Key | Action |
| ------- | --------- |
| `↑`/`k` | Move up |
| `↓`/`j` | Move down |
| `e` | Edit cell |
| `q` | Back |
| `esc` | Back |
### SQL Query Mode
| Key | Action |
| ------- | --------------- |
| `enter` | Execute query |
| `esc` | Back to tables |
| `↑`/`k` | Navigate results |
| `↓`/`j` | Navigate results |
| `e` | Edit query |
### Edit Cell
| Key | Action |
| ------- | ------ |
| `enter` | Save |
| `esc` | Cancel |
## License
[0BSD](LICENSE)

21
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/taigrr/teaqlite
go 1.26.1
go 1.26.2
require (
github.com/charmbracelet/bubbles v1.0.0
@@ -8,7 +8,7 @@ require (
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.10.2
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.48.2
)
require (
@@ -16,10 +16,10 @@ require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260311145557-c83711a11ffa // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260412004207-d48a6f9a4964 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
@@ -29,10 +29,10 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.2.0 // indirect
@@ -47,10 +47,9 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
modernc.org/libc v1.70.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

45
go.sum
View File

@@ -18,14 +18,14 @@ github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGy
github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188 h1:J8v4kWJYCaxv1SLhLunN74S+jMteZ1f7Dae99ioq4Bo=
github.com/charmbracelet/ultraviolet v0.0.0-20260309091805-903bfd0cf188/go.mod h1:FzWNAbe1jEmI+GZljSnlaSA8wJjnNIZhWBLkTsAl6eg=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b h1:ASDO9RT6SNKTQN87jO2bRfxHFJq8cgeYdFzivY2gCeM=
github.com/charmbracelet/ultraviolet v0.0.0-20260330092749-0f94982c930b/go.mod h1:Vo8TffMf0q7Uho/n8e6XpBZvOWtd3g39yX+9P5rRutA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260311145557-c83711a11ffa h1:/hY9CTFQJJ7G5Hu0MFAZTUXV/JO8H8FOIdWKvRA+tTw=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260311145557-c83711a11ffa/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260412004207-d48a6f9a4964 h1:PsOPE3HrHRy0TtAmoUVecs6Mv00CeC9zbMqJWCK2Mds=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260412004207-d48a6f9a4964/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -53,14 +53,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -101,20 +101,19 @@ golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -123,8 +122,8 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -133,8 +132,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -0,0 +1,109 @@
package app
import (
"testing"
_ "modernc.org/sqlite"
)
func TestEditCellModelDefaults(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1 // users
_ = shared.LoadTableData()
m := NewEditCellModel(shared, 0, 1) // row 0, col 1 (name)
if !m.Focused() {
t.Error("should be focused by default")
}
if m.ID() == 0 {
t.Error("ID should be non-zero")
}
if m.rowIndex != 0 {
t.Errorf("expected rowIndex 0, got %d", m.rowIndex)
}
if m.colIndex != 1 {
t.Errorf("expected colIndex 1, got %d", m.colIndex)
}
}
func TestEditCellModelFocusBlur(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
m := NewEditCellModel(shared, 0, 1)
m.Blur()
if m.Focused() {
t.Error("should not be focused after Blur()")
}
m.Focus()
if !m.Focused() {
t.Error("should be focused after Focus()")
}
}
func TestEditCellModelWithOptions(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
km := DefaultEditCellKeyMap()
m := NewEditCellModel(shared, 0, 1, WithEditCellKeyMap(km))
if !m.Focused() {
t.Error("should be focused")
}
}
func TestEditCellModelView(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Width = 80
shared.Height = 24
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
m := NewEditCellModel(shared, 0, 1) // editing "name" column
view := m.View()
if !containsSubstring(view, "Edit Cell") {
t.Error("view should contain title")
}
if !containsSubstring(view, "name") {
t.Error("view should show column name")
}
}
func TestEditCellModelOutOfBounds(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
// Out of bounds row/col should not panic
m := NewEditCellModel(shared, 99, 99)
if m == nil {
t.Error("should create model even with out of bounds indices")
}
}

231
internal/app/query_test.go Normal file
View File

@@ -0,0 +1,231 @@
package app
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
)
func TestExtractTableName(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
m := NewQueryModel(shared)
tests := []struct {
name string
query string
want string
}{
{"simple select", "SELECT * FROM users", "users"},
{"select with where", "SELECT name FROM products WHERE price > 10", "products"},
{"case insensitive", "select * from Users", "Users"},
{"with alias", "SELECT * FROM users u WHERE u.id = 1", "users"},
{"quoted table", `SELECT * FROM "users"`, "users"},
{"backtick table", "SELECT * FROM `users`", "users"},
{"no from clause", "SELECT 1", ""},
{"empty query", "", ""},
{"with limit", "SELECT * FROM users LIMIT 10", "users"},
{"with join", "SELECT * FROM users JOIN products ON users.id = products.id", "users"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.extractTableName(tt.query)
if got != tt.want {
t.Errorf("extractTableName(%q) = %q, want %q", tt.query, got, tt.want)
}
})
}
}
func TestGetTablePrimaryKeys(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewQueryModel(shared)
pks := m.getTablePrimaryKeys("users")
if len(pks) != 1 || pks[0] != "id" {
t.Errorf("expected [id], got %v", pks)
}
pks = m.getTablePrimaryKeys("nonexistent")
if len(pks) != 0 {
t.Errorf("expected empty for nonexistent table, got %v", pks)
}
}
func TestEnsureIDColumns(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewQueryModel(shared)
tests := []struct {
name string
query string
contains string // expected substring in result
unchanged bool // if true, expect query unchanged
}{
{"select star unchanged", "SELECT * FROM users", "SELECT *", true},
{"already has id", "SELECT id, name FROM users", "SELECT id", true},
{"adds id column", "SELECT name, email FROM users", "id", false},
{"non-select unchanged", "INSERT INTO users VALUES (1, 'test', 'test@test.com')", "INSERT", true},
{"no table found", "SELECT 1", "SELECT 1", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := m.ensureIDColumns(tt.query)
if tt.unchanged && got != tt.query {
t.Errorf("expected unchanged query, got %q", got)
}
if tt.contains != "" && !containsStr(got, tt.contains) {
t.Errorf("expected result to contain %q, got %q", tt.contains, got)
}
})
}
}
func containsStr(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
}
func containsSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func TestQueryModelNewDefaults(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
m := NewQueryModel(shared)
if !m.FocusOnInput {
t.Error("new query model should focus on input")
}
if !m.Focused() {
t.Error("new query model should be focused")
}
if m.selectedRow != 0 {
t.Errorf("expected selectedRow 0, got %d", m.selectedRow)
}
if m.ID() == 0 {
t.Error("ID should be non-zero")
}
}
func TestQueryModelFocusBlur(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
m := NewQueryModel(shared)
m.Blur()
if m.Focused() {
t.Error("should not be focused after Blur()")
}
m.Focus()
if !m.Focused() {
t.Error("should be focused after Focus()")
}
}
func TestQueryModelWithOptions(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
km := DefaultQueryKeyMap()
m := NewQueryModel(shared, WithQueryKeyMap(km))
if !m.Focused() {
t.Error("should be focused")
}
}
func TestHandleQueryCompletion(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewQueryModel(shared)
// Successful completion
msg := QueryCompletedMsg{
Results: [][]string{{"1", "Alice", "alice@example.com"}},
Columns: []string{"id", "name", "email"},
Error: nil,
}
m.handleQueryCompletion(msg)
if m.FocusOnInput {
t.Error("should not focus on input after completion")
}
if len(m.results) != 1 {
t.Errorf("expected 1 result, got %d", len(m.results))
}
if len(m.columns) != 3 {
t.Errorf("expected 3 columns, got %d", len(m.columns))
}
if !m.Shared.IsQueryResult {
t.Error("shared should be marked as query result")
}
// Error completion
m2 := NewQueryModel(shared)
errMsg := QueryCompletedMsg{
Error: sql.ErrNoRows,
}
m2.handleQueryCompletion(errMsg)
if m2.err == nil {
t.Error("expected error to be set")
}
}
func TestQueryModelView(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Width = 80
shared.Height = 24
m := NewQueryModel(shared)
// Basic view with input focused
view := m.View()
if !containsSubstring(view, "SQL Query") {
t.Error("view should contain title")
}
if !containsSubstring(view, "Query:") {
t.Error("view should contain query label")
}
// View with results
m.handleQueryCompletion(QueryCompletedMsg{
Results: [][]string{{"1", "Alice"}, {"2", "Bob"}},
Columns: []string{"id", "name"},
})
view = m.View()
if !containsSubstring(view, "2 rows returned") {
t.Error("view should show row count")
}
}

View File

@@ -0,0 +1,84 @@
package app
import (
"testing"
_ "modernc.org/sqlite"
)
func TestRowDetailModelDefaults(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
m := NewRowDetailModel(shared, 0)
if !m.Focused() {
t.Error("should be focused by default")
}
if m.ID() == 0 {
t.Error("ID should be non-zero")
}
}
func TestRowDetailModelFocusBlur(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
m := NewRowDetailModel(shared, 0)
m.Blur()
if m.Focused() {
t.Error("should not be focused after Blur()")
}
m.Focus()
if !m.Focused() {
t.Error("should be focused after Focus()")
}
}
func TestRowDetailModelView(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Width = 80
shared.Height = 24
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
m := NewRowDetailModel(shared, 0)
view := m.View()
if !containsSubstring(view, "Row Detail") {
t.Error("view should contain title")
}
}
func TestRowDetailModelWithOptions(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
shared.SelectedTable = 1
_ = shared.LoadTableData()
km := DefaultRowDetailKeyMap()
m := NewRowDetailModel(shared, 0, WithRowDetailKeyMap(km))
if !m.Focused() {
t.Error("should be focused")
}
}

View File

@@ -0,0 +1,255 @@
package app
import (
"database/sql"
"testing"
_ "modernc.org/sqlite"
)
func TestTableListModelDefaults(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewTableListModel(shared)
if !m.Focused() {
t.Error("should be focused by default")
}
if m.selectedTable != 0 {
t.Errorf("expected selectedTable 0, got %d", m.selectedTable)
}
if m.searching {
t.Error("should not be searching by default")
}
if m.ID() == 0 {
t.Error("ID should be non-zero")
}
}
func TestTableListModelFocusBlur(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewTableListModel(shared)
m.Blur()
if m.Focused() {
t.Error("should not be focused after Blur()")
}
m.Focus()
if !m.Focused() {
t.Error("should be focused after Focus()")
}
}
func TestTableListModelWithOptions(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
km := DefaultTableListKeyMap()
m := NewTableListModel(shared, WithTableListKeyMap(km))
if !m.Focused() {
t.Error("should be focused")
}
}
func TestFuzzyScore(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
m := NewTableListModel(shared)
tests := []struct {
name string
text string
pattern string
wantZero bool
wantAbove int // minimum expected score
}{
{"exact match", "users", "users", false, 999},
{"prefix match", "users", "use", false, 899},
{"contains match", "all_users", "user", false, 799},
{"fuzzy match", "user_table", "ut", false, 1},
{"no match", "users", "xyz", true, 0},
{"empty pattern", "users", "", false, 0},
{"pattern longer than text", "ab", "abcd", true, 0},
{"word boundary match", "user_name", "un", false, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
score := m.fuzzyScore(tt.text, tt.pattern)
if tt.wantZero && score != 0 {
t.Errorf("fuzzyScore(%q, %q) = %d, want 0", tt.text, tt.pattern, score)
}
if !tt.wantZero && score <= tt.wantAbove-1 {
t.Errorf("fuzzyScore(%q, %q) = %d, want > %d", tt.text, tt.pattern, score, tt.wantAbove-1)
}
})
}
// Exact match should score higher than prefix match
exactScore := m.fuzzyScore("users", "users")
prefixScore := m.fuzzyScore("users_table", "users")
if exactScore <= prefixScore {
t.Errorf("exact match (%d) should score higher than prefix match (%d)", exactScore, prefixScore)
}
}
func TestFilterTables(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewTableListModel(shared)
// Empty search returns all tables
m.searchInput.SetValue("")
m.filterTables()
if len(m.Shared.FilteredTables) != 2 {
t.Errorf("expected 2 tables with empty filter, got %d", len(m.Shared.FilteredTables))
}
// Filter for "user" should match "users"
m.searchInput.SetValue("user")
m.filterTables()
if len(m.Shared.FilteredTables) != 1 || m.Shared.FilteredTables[0] != "users" {
t.Errorf("expected [users], got %v", m.Shared.FilteredTables)
}
// Filter for "prod" should match "products"
m.searchInput.SetValue("prod")
m.filterTables()
if len(m.Shared.FilteredTables) != 1 || m.Shared.FilteredTables[0] != "products" {
t.Errorf("expected [products], got %v", m.Shared.FilteredTables)
}
// Filter with no match
m.searchInput.SetValue("zzzzz")
m.filterTables()
if len(m.Shared.FilteredTables) != 0 {
t.Errorf("expected 0 tables, got %d", len(m.Shared.FilteredTables))
}
// Selected table resets when filter has no results
if m.selectedTable != 0 {
t.Errorf("selectedTable should reset to 0 when no results, got %d", m.selectedTable)
}
}
func TestGetVisibleCount(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Height = 24
_ = shared.LoadTables()
m := NewTableListModel(shared)
count := m.getVisibleCount()
if count != 16 { // 24 - 8 reserved lines
t.Errorf("expected 16 visible count, got %d", count)
}
// While searching, fewer visible
m.searching = true
count = m.getVisibleCount()
if count != 14 { // 24 - 10 reserved lines
t.Errorf("expected 14 visible count while searching, got %d", count)
}
// Very small terminal
shared.Height = 5
m.searching = false
count = m.getVisibleCount()
if count < 1 {
t.Error("visible count should be at least 1")
}
}
func TestAdjustPage(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Height = 24
_ = shared.LoadTables()
m := NewTableListModel(shared)
m.selectedTable = 0
m.adjustPage()
if m.currentPage != 0 {
t.Errorf("expected page 0, got %d", m.currentPage)
}
}
func TestTableListModelView(t *testing.T) {
db := createTestDB(t)
defer db.Close()
shared := NewSharedData(db)
shared.Width = 80
shared.Height = 24
_ = shared.LoadTables()
m := NewTableListModel(shared)
view := m.View()
if !containsSubstring(view, "SQLite TUI") {
t.Error("view should contain title")
}
if !containsSubstring(view, "products") {
t.Error("view should contain table names")
}
if !containsSubstring(view, "users") {
t.Error("view should contain table names")
}
// View while searching
m.searching = true
view = m.View()
if !containsSubstring(view, "Search:") {
t.Error("view should show search input while searching")
}
// View with filter applied
m.searching = false
m.searchInput.SetValue("user")
m.filterTables()
view = m.View()
if !containsSubstring(view, "Filtered by:") {
t.Error("view should show filter info")
}
}
func TestTableListModelViewEmptyDB(t *testing.T) {
db, err := openTestDB(t)
if err != nil {
t.Fatal(err)
}
defer db.Close()
shared := NewSharedData(db)
_ = shared.LoadTables()
m := NewTableListModel(shared)
view := m.View()
if !containsSubstring(view, "No tables found") {
t.Error("empty db view should show 'No tables found'")
}
}
func openTestDB(t *testing.T) (*sql.DB, error) {
t.Helper()
return sql.Open("sqlite", ":memory:")
}