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:
2026-08-09 01:12:48 +02:00
co-authored by Claude Opus 5
parent 9ba3fe8fbe
commit 6d4266c224
3 changed files with 227 additions and 11 deletions
+123
View File
@@ -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()