Run imports in the background behind a spinner
Import ran inline in the update loop, so the whole interface froze for as long as pdftotext took over a stack of statements, with no way to tell work in progress apart from a hang. It now runs as a command off the event loop, with a spinner and a note that PDFs take a while. A repeat i press is ignored and retag is refused while an import is running, so nothing writes to the index concurrently. The result arrives as a message carrying the counts, the first failure, and the first warning with a count of any others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"git.petrovv.com/nikola/money/internal/config"
|
||||
"git.petrovv.com/nikola/money/internal/importer"
|
||||
"git.petrovv.com/nikola/money/internal/model"
|
||||
"git.petrovv.com/nikola/money/internal/rules"
|
||||
"git.petrovv.com/nikola/money/internal/store"
|
||||
@@ -257,6 +261,125 @@ func TestReportViewExcludesTransfers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Import must not block the event loop: pressing i starts a background command
|
||||
// and puts the view into a spinning state that keeps redrawing.
|
||||
func TestImportShowsSpinnerAndDoesNotBlock(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||
if !m.importing {
|
||||
t.Fatal("expected the model to be in the importing state")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected a command to run the import in the background")
|
||||
}
|
||||
if view := m.View(); !strings.Contains(view, "importing") {
|
||||
t.Errorf("expected the status line to say it is importing:\n%s", view)
|
||||
}
|
||||
|
||||
// A tick advances the spinner and schedules the next frame.
|
||||
_, tickCmd := m.Update(spinner.TickMsg{Time: time.Now()})
|
||||
if tickCmd == nil {
|
||||
t.Error("expected the spinner to schedule another tick while importing")
|
||||
}
|
||||
|
||||
// Pressing i again must not start a second concurrent import.
|
||||
if _, again := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}}); again != nil {
|
||||
t.Error("expected a repeat i press to be ignored while importing")
|
||||
}
|
||||
// Nor may retag run against the database mid-import.
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}})
|
||||
if !strings.Contains(m.status, "import in progress") {
|
||||
t.Errorf("status = %q, want retag to be refused during an import", m.status)
|
||||
}
|
||||
|
||||
// The result arriving clears the spinner and reports what happened.
|
||||
m.Update(importDoneMsg{res: importer.Result{
|
||||
Files: []importer.FileResult{{Path: "checking/st.csv", New: 3, Skipped: 1}},
|
||||
}})
|
||||
if m.importing {
|
||||
t.Error("expected the importing state to clear")
|
||||
}
|
||||
if !strings.Contains(m.status, "3 new") || !strings.Contains(m.status, "1 duplicate") {
|
||||
t.Errorf("status = %q, want the import counts", m.status)
|
||||
}
|
||||
// A stale tick after the import finished must not restart the spinner.
|
||||
if _, cmd := m.Update(spinner.TickMsg{Time: time.Now()}); cmd != nil {
|
||||
t.Error("expected a tick after the import to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
// The spinner must keep ticking for as long as the import runs: one frame and
|
||||
// then silence looks exactly like the freeze it is meant to rule out.
|
||||
func TestSpinnerKeepsTicking(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||
|
||||
// Drive the loop the way Bubble Tea does: run the command, feed the
|
||||
// message it produces back into Update, and repeat.
|
||||
var frames []string
|
||||
cmd := tea.Cmd(m.spinner.Tick)
|
||||
for i := 0; i < 4; i++ {
|
||||
if cmd == nil {
|
||||
t.Fatalf("tick %d: no command scheduled, the spinner stopped", i)
|
||||
}
|
||||
msg := cmd()
|
||||
if msg == nil {
|
||||
t.Fatalf("tick %d: command produced no message", i)
|
||||
}
|
||||
_, cmd = m.Update(msg)
|
||||
frames = append(frames, m.spinner.View())
|
||||
}
|
||||
|
||||
if len(unique(frames)) < 2 {
|
||||
t.Errorf("spinner rendered %v; want the frame to advance between ticks", frames)
|
||||
}
|
||||
}
|
||||
|
||||
func unique(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if !seen[s] {
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestImportFailureIsReported(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||
|
||||
m.Update(importDoneMsg{err: errors.New("index is locked")})
|
||||
if m.importing {
|
||||
t.Error("expected the importing state to clear on failure")
|
||||
}
|
||||
if m.err == nil || !strings.Contains(m.err.Error(), "index is locked") {
|
||||
t.Errorf("err = %v, want the failure surfaced", m.err)
|
||||
}
|
||||
}
|
||||
|
||||
// A per-file warning is worth seeing without leaving the TUI.
|
||||
func TestImportWarningsSurface(t *testing.T) {
|
||||
m, _ := newTestModel(t)
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'i'}})
|
||||
|
||||
m.Update(importDoneMsg{res: importer.Result{Files: []importer.FileResult{{
|
||||
Path: "revolut/statement.csv",
|
||||
New: 2,
|
||||
Warnings: []string{"skipped 1 PENDING transactions", "skipped 3 rows in JPY"},
|
||||
}}}})
|
||||
|
||||
if !strings.Contains(m.status, "skipped 1 PENDING") {
|
||||
t.Errorf("status = %q, want the first warning", m.status)
|
||||
}
|
||||
if !strings.Contains(m.status, "+1 more") {
|
||||
t.Errorf("status = %q, want a count of the remaining warnings", m.status)
|
||||
}
|
||||
}
|
||||
|
||||
// newEmptyModel builds a model over an index with nothing in it.
|
||||
func newEmptyModel(t *testing.T, accounts []*config.Account) *Model {
|
||||
t.Helper()
|
||||
|
||||
Reference in New Issue
Block a user