prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell_test.go (9952B)


      1 package tui
      2 
      3 import (
      4 	"math/rand"
      5 	"regexp"
      6 	"strings"
      7 	"testing"
      8 	"time"
      9 
     10 	appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
     11 	"github.com/charmbracelet/bubbles/help"
     12 	"github.com/charmbracelet/bubbles/viewport"
     13 	tea "github.com/charmbracelet/bubbletea"
     14 	"github.com/charmbracelet/lipgloss"
     15 	"github.com/charmbracelet/x/ansi"
     16 	"github.com/muesli/termenv"
     17 )
     18 
     19 // testModel builds a model without network or git access.
     20 func testModel(t *testing.T, w, h int) portfolioModel {
     21 	t.Helper()
     22 	lipgloss.SetColorProfile(termenv.ANSI256)
     23 	exp := appconfig.GetExperience()
     24 	projects := appconfig.GetProjects()
     25 	m := portfolioModel{
     26 		user:                "visitor",
     27 		about:               appconfig.GetAbout(),
     28 		currentJob:          appconfig.CurrentJob(exp),
     29 		aboutASCIICache:     map[int]string{},
     30 		aboutASCIILoads:     map[int]bool{},
     31 		projects:            projects,
     32 		experience:          exp,
     33 		now:                 time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC),
     34 		width:               w,
     35 		height:              h,
     36 		bootRand:            rand.New(rand.NewSource(1)),
     37 		keys:                defaultKeyMap(),
     38 		help:                help.New(),
     39 		viewport:            viewport.New(1, 1),
     40 		shell:               newShellState("visitor"),
     41 		projectReadmes:      map[string]string{projects[0].ID: "# janitor\n\nsorts audio."},
     42 		projectReadmeErrors: map[string]string{},
     43 		projectReadmeLoads:  map[string]bool{},
     44 		projectReadmeRender: map[string]map[int]string{},
     45 	}
     46 	m.syncViewport()
     47 	return m
     48 }
     49 
     50 func typeString(m *portfolioModel, s string) tea.Cmd {
     51 	var cmd tea.Cmd
     52 	for _, r := range s {
     53 		if r == '\n' {
     54 			cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
     55 			continue
     56 		}
     57 		if r == ' ' {
     58 			cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
     59 			continue
     60 		}
     61 		cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
     62 	}
     63 	return cmd
     64 }
     65 
     66 func isQuit(cmd tea.Cmd) bool {
     67 	if cmd == nil {
     68 		return false
     69 	}
     70 	_, ok := cmd().(tea.QuitMsg)
     71 	return ok
     72 }
     73 
     74 func plainView(m portfolioModel) string { return ansi.Strip(m.View()) }
     75 
     76 func TestShellPromptRendersOnAboutPage(t *testing.T) {
     77 	m := testModel(t, 120, 60)
     78 	if !strings.Contains(plainView(m), "visitor@prime8:~$ █") {
     79 		t.Fatalf("prompt not rendered:\n%s", plainView(m))
     80 	}
     81 }
     82 
     83 func TestShellTypingDoesNotTriggerShortcuts(t *testing.T) {
     84 	m := testModel(t, 120, 60)
     85 	// q, f, b, h are bindings elsewhere; on the about page they are text.
     86 	if cmd := typeString(&m, "qfbh"); isQuit(cmd) {
     87 		t.Fatal("q quit the session while typing")
     88 	}
     89 	if m.shell.input != "qfbh" {
     90 		t.Fatalf("input = %q", m.shell.input)
     91 	}
     92 	if !strings.Contains(plainView(m), "visitor@prime8:~$ qfbh█") {
     93 		t.Fatalf("typed text not shown:\n%s", plainView(m))
     94 	}
     95 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyBackspace})
     96 	if m.shell.input != "qfb" {
     97 		t.Fatalf("backspace: input = %q", m.shell.input)
     98 	}
     99 }
    100 
    101 func TestShellTabSwitchingStillWorks(t *testing.T) {
    102 	m := testModel(t, 120, 60)
    103 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
    104 	if m.activeTab != tabProjects {
    105 		t.Fatalf("digit shortcut with empty prompt: tab = %d", m.activeTab)
    106 	}
    107 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
    108 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
    109 	if m.activeTab != tabExperience {
    110 		t.Fatalf("tab key with empty prompt: tab = %d", m.activeTab)
    111 	}
    112 }
    113 
    114 func TestShellExitQuits(t *testing.T) {
    115 	m := testModel(t, 120, 60)
    116 	if cmd := typeString(&m, "exit\n"); !isQuit(cmd) {
    117 		t.Fatal("exit did not quit")
    118 	}
    119 }
    120 
    121 func TestShellCommands(t *testing.T) {
    122 	m := testModel(t, 120, 80)
    123 	typeString(&m, "ls\n")
    124 	typeString(&m, "cd projects\n")
    125 	typeString(&m, "pwd\n")
    126 	typeString(&m, "ls\n")
    127 	typeString(&m, "cat janitor/README.md\n")
    128 	typeString(&m, "cat nope\n")
    129 	typeString(&m, "cd ../bio\n")
    130 	typeString(&m, "rm -rf /\n")
    131 	typeString(&m, "cat ../../../../etc/passwd\n")
    132 	out := plainView(m)
    133 	for _, want := range []string{
    134 		"about  bio  contact  experience/  hobbies/  interests/  languages/  projects/",
    135 		"visitor@prime8:~/projects$ pwd",
    136 		"/home/visitor/projects",
    137 		"finger/  janitor/  summarizer/",
    138 		"sorts audio.",
    139 		"cat: nope: No such file or directory",
    140 		"cd: ../bio: Not a directory",
    141 		"rm: it is dangerous to operate recursively on '/'",
    142 		"cat: ../../../../etc/passwd: No such file or directory",
    143 		"visitor@prime8:~/projects$ █",
    144 	} {
    145 		if !strings.Contains(out, want) {
    146 			t.Errorf("missing %q in:\n%s", want, out)
    147 		}
    148 	}
    149 }
    150 
    151 func TestShellClear(t *testing.T) {
    152 	m := testModel(t, 120, 60)
    153 	typeString(&m, "ls\nclear\n")
    154 	out := plainView(m)
    155 	if strings.Contains(out, "cat bio") || strings.Contains(out, "experience/") {
    156 		t.Fatalf("clear left output behind:\n%s", out)
    157 	}
    158 	if !strings.Contains(out, "visitor@prime8:~$ █") {
    159 		t.Fatalf("prompt missing after clear:\n%s", out)
    160 	}
    161 }
    162 
    163 func TestShellCompletion(t *testing.T) {
    164 	m := testModel(t, 120, 60)
    165 	typeString(&m, "cat proj")
    166 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
    167 	if m.shell.input != "cat projects/" {
    168 		t.Fatalf("completion: %q", m.shell.input)
    169 	}
    170 	typeString(&m, "ja")
    171 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
    172 	if m.shell.input != "cat projects/janitor/" {
    173 		t.Fatalf("completion: %q", m.shell.input)
    174 	}
    175 }
    176 
    177 func TestShellCompletionRelativeToCwd(t *testing.T) {
    178 	m := testModel(t, 120, 60)
    179 	typeString(&m, "cd projects\n")
    180 	typeString(&m, "cat ja")
    181 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
    182 	if m.shell.input != "cat janitor/" {
    183 		t.Fatalf("completion in cwd: %q", m.shell.input)
    184 	}
    185 	m.shell.input = "ls /ho"
    186 	m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
    187 	if m.shell.input != "ls /home/" {
    188 		t.Fatalf("absolute completion: %q", m.shell.input)
    189 	}
    190 }
    191 
    192 func TestShellUsesSessionUser(t *testing.T) {
    193 	m := testModel(t, 120, 80)
    194 	m.user = defaultUser("Dam/ian")
    195 	m.shell = newShellState(m.user)
    196 	m.syncViewport()
    197 	typeString(&m, "whoami\n")
    198 	typeString(&m, "pwd\n")
    199 	typeString(&m, "sudo ls\n")
    200 	typeString(&m, "cd ~/projects\n")
    201 	out := plainView(m)
    202 	for _, want := range []string{
    203 		"damian@prime8:~$ whoami",
    204 		"/home/damian",
    205 		"damian is not in the sudoers file.",
    206 		"damian@prime8:~/projects$ █",
    207 	} {
    208 		if !strings.Contains(out, want) {
    209 			t.Errorf("missing %q in:\n%s", want, out)
    210 		}
    211 	}
    212 	if strings.Contains(out, "guest") {
    213 		t.Errorf("guest leaked:\n%s", out)
    214 	}
    215 	if got := m.windowTitle(); got != "prime8:~" {
    216 		t.Errorf("window title = %q", got)
    217 	}
    218 	m.shell = newShellState(m.user)
    219 	m.syncViewport()
    220 	if out := plainView(m); !strings.Contains(out, "damian@prime8:~$ cat bio") {
    221 		t.Errorf("intro prompt not using session user:\n%s", out)
    222 	}
    223 }
    224 
    225 func TestWindowTitleIsFull(t *testing.T) {
    226 	m := testModel(t, 120, 40)
    227 	m.activateTab(tabExperience)
    228 	if got := m.windowTitle(); got != "prime8:~/experience" {
    229 		t.Errorf("experience title = %q", got)
    230 	}
    231 	m.activateTab(tabProjects)
    232 	want := "prime8:~/projects/" + m.selectedProject().ID
    233 	if got := m.windowTitle(); got != want {
    234 		t.Errorf("project title = %q, want %q", got, want)
    235 	}
    236 }
    237 
    238 func TestProjectsSharedBorderIsAccent(t *testing.T) {
    239 	accent := "\x1b[33m"
    240 	for _, w := range []int{120, 90} {
    241 		m := testModel(t, w, 30)
    242 		m.activateTab(tabProjects)
    243 		for _, readme := range []bool{false, true} {
    244 			m.projectReadmeScroll = readme
    245 			view := m.View()
    246 			lines := strings.Split(view, "\n")
    247 			// every line holding a divider glyph must draw it in the accent color
    248 			found := false
    249 			for _, line := range lines {
    250 				for _, glyph := range []string{"┬", "┴", "├", "┤"} {
    251 					i := strings.Index(line, glyph)
    252 					if i < 0 {
    253 						continue
    254 					}
    255 					found = true
    256 					if j := strings.LastIndex(line[:i], "\x1b["); j < 0 || !strings.HasPrefix(line[j:], accent) {
    257 						t.Errorf("w=%d readme=%v: %s not accent-colored", w, readme, glyph)
    258 					}
    259 				}
    260 			}
    261 			if !found {
    262 				t.Errorf("w=%d readme=%v: no shared border junction found", w, readme)
    263 			}
    264 			for _, l := range strings.Split(ansi.Strip(view), "\n") {
    265 				if lipgloss.Width(l) > w {
    266 					t.Errorf("w=%d readme=%v: line overflows (%d)", w, readme, lipgloss.Width(l))
    267 				}
    268 			}
    269 		}
    270 	}
    271 }
    272 
    273 // The chrome must follow the terminal theme: no fixed 256/truecolor
    274 // colors and no painted backgrounds outside tags and the logo.
    275 func TestChromeUsesTerminalPalette(t *testing.T) {
    276 	sgr := regexp.MustCompile(`\x1b\[([0-9;]*)m`)
    277 	for _, profile := range []termenv.Profile{termenv.TrueColor, termenv.ANSI256} {
    278 		m := testModel(t, 120, 40)
    279 		lipgloss.SetColorProfile(profile) // as the SSH handler does
    280 		m.wakaHours = "1,234 hrs"
    281 		// tags carry fixed brand fg/bg pairs on purpose; leave them out
    282 		m.about.Interests, m.about.Hobbies, m.about.Languages = nil, nil, nil
    283 		for i := range m.experience {
    284 			m.experience[i].Tech = nil
    285 		}
    286 		for _, tab := range []int{tabAbout, tabExperience} {
    287 			m.activateTab(tab)
    288 			view := m.View()
    289 			reverse := false
    290 			for _, match := range sgr.FindAllStringSubmatch(view, -1) {
    291 				params := strings.Split(match[1], ";")
    292 				for k := 0; k < len(params); k++ {
    293 					p := params[k]
    294 					switch {
    295 					case p == "38" || p == "48":
    296 						t.Errorf("profile %v tab %d: extended color %q", profile, tab, match[0])
    297 						k = len(params)
    298 					case len(p) == 2 && p[0] == '4' && p != "49", len(p) == 3 && p[:2] == "10":
    299 						t.Errorf("profile %v tab %d: background %q", profile, tab, match[0])
    300 					case p == "7":
    301 						reverse = true
    302 					}
    303 				}
    304 			}
    305 			if !reverse {
    306 				t.Errorf("profile %v tab %d: active tab not in reverse video", profile, tab)
    307 			}
    308 		}
    309 	}
    310 }
    311 
    312 func TestCursorShownAfterTabSwitch(t *testing.T) {
    313 	m := testModel(t, 120, 40)
    314 	m.shell.lastKey = time.Now().Add(-cursorBlink * 3) // mid "off" phase
    315 	m.activateTab(tabProjects)
    316 	m.activateTab(tabAbout)
    317 	if out := plainView(m); !strings.Contains(out, "█") {
    318 		t.Fatalf("cursor missing after switching back to about")
    319 	}
    320 	m.shell.lastKey = time.Now().Add(-cursorBlink * 3)
    321 	next, _ := m.Update(cursorBlinkMsg{})
    322 	if out := plainView(next.(portfolioModel)); strings.Contains(out, "█") {
    323 		t.Fatalf("blink tick did not re-render the prompt (cursor stuck on)")
    324 	}
    325 }