Files
gnoma/internal/tui/completions_test.go
vikingowl e38cce5f1f fix(tui): security hardening, race-safety, and event handling fixes
Bundles the pending TUI work into a coherent batch. Bug fixes from
external review:

* expandPlaceholders: single-pass alternation regex over the original
  input prevents `#p\d+` / `#img\d+` tokens inside pasted content from
  being re-expanded after the bracket form is inlined.
* /incognito: gate savePromptHistory and the Ctrl+V image-write branch
  on `!m.incognito` so the no-persistence contract holds.
* history.txt: write at mode 0600 (chmod existing 0644 files), create
  parent dir at 0700, truncate to 500 entries on every save, slog.Warn
  on errors instead of swallowing.
* triggerPickerAction: guard m.config.Engine before SetModel, matching
  the /model handler.
* Picker key handler: navigation/enter/q consume, escape/ctrl+c close
  the picker AND fall through to global handlers (so streaming cancel
  and double-tap quit work with an overlay open), default swallows
  stray input.
* Paste line count: report total non-empty lines instead of newline
  count, ignoring trailing newlines (no more "+0 lines" for "abc").
* Ctrl+O restored to expand-output; Ctrl+Y is the new copy-response
  bind. /keys help text updated; picker help entries reordered.
* Tighter perms on .gnoma/pasted_image_*.png (0600).

Race-safety refactor: ApplyTheme used to mutate ~25 package-level
lipgloss styles in place. Replaced with an immutable themeStyles
snapshot and atomic.Pointer[themeStyles] swap. Readers go through a
theme() helper (one atomic load) instead of touching package vars
directly. No locks, no nested-RLock risk if rendering ever moves
off-thread.

Includes pre-existing in-flight work: TUISection in config with
persistent theme/vim settings; /copy /theme /vim slash commands;
provider-name completion; session.SetProvider for the provider picker.

Tests: placeholder_test.go (6 regression + happy-path cases including
the pasted-content collision), history_test.go (5 cases covering perms
on new and existing files, on-disk truncation, blank-input, newline
flattening), provider_test.go (provider switching + picker transitions
+ SLM gating).
2026-05-22 11:50:12 +02:00

191 lines
5.4 KiB
Go

package tui
import (
"testing"
)
func TestMatchCompletion(t *testing.T) {
cmds := []cmdEntry{
{"/clear", "clear history"},
{"/compact", "compact context"},
{"/config", "settings"},
{"/help", "show help"},
{"/model", "switch model"},
{"/permission", "set permission"},
{"/quit", "quit"},
}
tests := []struct {
input string
want string
}{
{"/h", "/help"},
{"/he", "/help"},
{"/help", ""}, // already complete
{"/cl", "/clear"}, // unambiguous prefix
{"/co", ""}, // ambiguous: /compact, /config
{"/com", "/compact"},
{"/con", "/config"},
{"/q", "/quit"},
{"/model ", ""}, // has args — no command completion
{"hello", ""}, // not a slash command
{"/", ""}, // too short
{"/x", ""}, // no match
}
for _, tt := range tests {
got := matchCompletion(tt.input, cmds, nil, nil)
if got != tt.want {
t.Errorf("matchCompletion(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFuzzyMatch(t *testing.T) {
tests := []struct {
pattern string
text string
want bool
}{
{"hlp", "help", true},
{"clr", "clear", true},
{"mdl", "model", true},
{"help", "help", true}, // exact match
{"HELP", "help", true}, // case insensitive
{"xyz", "help", false}, // no match
{"", "help", true}, // empty pattern matches everything
{"hx", "help", false}, // x not present
{"elp", "help", true}, // subsequence not at start
}
for _, tt := range tests {
got := fuzzyMatch(tt.pattern, tt.text)
if got != tt.want {
t.Errorf("fuzzyMatch(%q, %q) = %v, want %v", tt.pattern, tt.text, got, tt.want)
}
}
}
func TestFuzzyMatchCommands(t *testing.T) {
cmds := []cmdEntry{
{"/clear", "clear history"},
{"/compact", "compact context"},
{"/config", "settings"},
{"/help", "show help"},
{"/model", "switch model"},
}
tests := []struct {
query string
wantLen int
wantFirst string
}{
{"", 5, "/clear"}, // empty = all commands
{"h", 1, "/help"}, // only /help contains h as subsequence
{"hel", 1, "/help"}, // only /help
{"mdl", 1, "/model"}, // subsequence match
{"xyz", 0, ""}, // no match
}
for _, tt := range tests {
got := fuzzyMatchCommands(tt.query, cmds)
if len(got) != tt.wantLen {
t.Errorf("fuzzyMatchCommands(%q): got %d results, want %d (got: %v)", tt.query, len(got), tt.wantLen, got)
}
if tt.wantFirst != "" && len(got) > 0 && got[0].name != tt.wantFirst {
t.Errorf("fuzzyMatchCommands(%q): first result = %q, want %q", tt.query, got[0].name, tt.wantFirst)
}
}
}
func TestMatchArgCompletion(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/permission a", "/permission auto"},
{"/permission au", "/permission auto"},
{"/permission auto", ""}, // already complete
{"/permission d", "/permission default"}, // first match
{"/perm b", "/perm bypass"},
{"/perm p", "/perm plan"},
{"/model foo", ""}, // no arg completion for /model yet
}
for _, tt := range tests {
got := matchArgCompletion(tt.input, nil, nil)
if got != tt.want {
t.Errorf("matchArgCompletion(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestMatchArgCompletion_Profile(t *testing.T) {
profiles := []string{"experiment", "private", "work"}
tests := []struct {
input string
want string
}{
{"/profile w", "/profile work"},
{"/profile p", "/profile private"},
{"/profile work", ""}, // already complete
{"/profile e", "/profile experiment"},
{"/profile z", ""}, // no match
{"/profile ", ""}, // empty arg — wait for input
}
for _, tt := range tests {
got := matchArgCompletion(tt.input, profiles, nil)
if got != tt.want {
t.Errorf("matchArgCompletion(%q, profiles) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestMatchCompletion_DispatchesToProfileArgCompletion(t *testing.T) {
// End-to-end: matchCompletion sees "/profile w", forwards to
// matchArgCompletion with profileNames, gets back "/profile work".
cmds := []cmdEntry{{"/profile", "profiles"}}
got := matchCompletion("/profile w", cmds, []string{"work", "private"}, nil)
if got != "/profile work" {
t.Errorf("matchCompletion(/profile w) = %q, want /profile work", got)
}
}
func TestMatchArgCompletion_ProfileNoNamesAvailable(t *testing.T) {
// When profile mode isn't engaged, profileNames is nil/empty and the
// completer must not try to suggest anything.
got := matchArgCompletion("/profile w", nil, nil)
if got != "" {
t.Errorf("matchArgCompletion(profile, nil) = %q, want empty", got)
}
}
func TestMatchArgCompletion_Provider(t *testing.T) {
providers := []string{"anthropic", "openai", "google"}
tests := []struct {
input string
want string
}{
{"/provider a", "/provider anthropic"},
{"/provider o", "/provider openai"},
{"/provider openai", ""}, // already complete
{"/provider g", "/provider google"},
{"/provider z", ""}, // no match
{"/provider ", ""}, // empty arg — wait for input
}
for _, tt := range tests {
got := matchArgCompletion(tt.input, nil, providers)
if got != tt.want {
t.Errorf("matchArgCompletion(%q, providers) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestMatchCompletion_DispatchesToProviderArgCompletion(t *testing.T) {
cmds := []cmdEntry{{"/provider", "providers"}}
got := matchCompletion("/provider a", cmds, nil, []string{"anthropic", "openai"})
if got != "/provider anthropic" {
t.Errorf("matchCompletion(/provider a) = %q, want /provider anthropic", got)
}
}