Sort the rule builder's preview by count

Alphabetical order finds a payee you are already looking at, which is what the
list is for once a glob is typed. With the glob empty it answers a different
question -- what to write a rule for next -- and there the name is the least
useful thing about a row: one pattern claiming forty rows is worth more than
the first of forty claiming one, and nothing on screen said which was which
until you read the whole list.

ctrl+s switches between the two. A chord rather than a letter because the
builder is a form and every printable key belongs to the field being typed in;
the invariant that keeps q from quitting there cuts this way too. Ties fall
back to the alphabetical order so the list does not reshuffle when it is
rebuilt, and the cursor returns to the top, since the row under it is not the
row that was there a moment ago.

The header marks the column the order is read from, and the help names what the
key does next rather than where the list already is. The choice is the user's,
so it outlives the builder being left and reopened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 20:33:46 +02:00
co-authored by Claude Opus 5
parent 6477147988
commit 656e7e1de4
4 changed files with 137 additions and 13 deletions
+61
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -77,6 +78,8 @@ func key(t *testing.T, m *Model, s string) {
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "ctrl+s":
msg = tea.KeyMsg{Type: tea.KeyCtrlS}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
}
@@ -2124,3 +2127,61 @@ func TestEscFromRuleListDoesNotBounceIntoTheBuilder(t *testing.T) {
t.Errorf("esc from the list went to %v, want the transactions it was opened from", m.view)
}
}
// Two orders, because the preview answers two questions: by name finds the
// payee you are looking at, by count finds the rule worth writing next.
func TestRuleBuilderSortsByCount(t *testing.T) {
m, _, _ := newRuleModel(t)
key(t, m, "4")
alphabetical := []string{
"AAA FIRST ALPHABETICALLY",
"LIDL SOFIA 4412",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Fatalf("preview = %v, want %v by default", all, alphabetical)
}
key(t, m, "ctrl+s")
// The twice-seen description leads; the rest tie on one and keep the
// alphabetical order between them, so the list does not reshuffle.
byCount := []string{
"LIDL SOFIA 4412",
"AAA FIRST ALPHABETICALLY",
"LIDL VARNA 9911",
"ZZZ LAST ALPHABETICALLY",
}
if all, _ := previewRows(m); !slices.Equal(all, byCount) {
t.Fatalf("preview = %v, want %v after sorting by count", all, byCount)
}
// The header names the column the order is read from, and the help names
// what the key does next rather than where the list already is.
if cols := m.ruleTable.Columns(); cols[2].Title != "N ↓" || strings.Contains(cols[1].Title, "↓") {
t.Errorf("headers = %q / %q, want the count column marked", cols[1].Title, cols[2].Title)
}
if !strings.Contains(m.help(), "ctrl+s sort by name") {
t.Errorf("help = %q, want the key to offer the way back", m.help())
}
key(t, m, "ctrl+s")
if all, _ := previewRows(m); !slices.Equal(all, alphabetical) {
t.Errorf("preview = %v, want %v back", all, alphabetical)
}
// The choice is the user's, so it survives a glob narrowing the list and
// the builder being left and reopened.
key(t, m, "ctrl+s")
typeText(t, m, "*LIDL*")
key(t, m, "esc")
key(t, m, "4")
if m.previewSort != sortByCount {
t.Fatal("the sort was reset by leaving the builder")
}
want := []string{"LIDL SOFIA 4412", "LIDL VARNA 9911"}
if all, _ := previewRows(m); !slices.Equal(all, want) {
t.Errorf("preview = %v, want %v: the glob still filters, in count order", all, want)
}
}