init
This commit is contained in:
commit
d295c7f139
1527 files changed
+270726
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,174 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func keyMsg(s string) tea.KeyMsg {
|
||||
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)}
|
||||
}
|
||||
|
||||
func newPDFTestModel(t *testing.T, careerOpsPath string, apps []model.CareerApplication) PipelineModel {
|
||||
t.Helper()
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
apps,
|
||||
model.PipelineMetrics{Total: len(apps)},
|
||||
careerOpsPath,
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.viewMode = "flat"
|
||||
pm.applyFilterAndSort()
|
||||
return pm
|
||||
}
|
||||
|
||||
func writePDFFixture(t *testing.T, root, rel string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("pdf"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyFlashesWhenNoPDFExists(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected no command when no PDF matches")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected a flash notice when no PDF matches")
|
||||
}
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker when no PDF matches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyOpensSingleMatchDirectly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-globex-2026-06-05.pdf")
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker for a single match")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected an open command for a single match")
|
||||
}
|
||||
msg, ok := cmd().(PipelineOpenPDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineOpenPDFMsg, got %T", cmd())
|
||||
}
|
||||
if !strings.HasSuffix(msg.Path, "cv-jane-doe-globex-2026-06-05.pdf") {
|
||||
t.Fatalf("unexpected PDF path %q", msg.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPDFKeyOpensNewestForMultipleMatches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Write two PDFs for the same company with distinct dates so ordering is predictable.
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-anthropic-2026-06-05.pdf")
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-anthropic-2026-06-10.pdf")
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Anthropic", Role: "Staff UI Engineer", Status: "Evaluated", Score: 4.6},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("d"))
|
||||
|
||||
// Multiple candidates → open the newest one immediately, no picker.
|
||||
if updated.pdfPicker {
|
||||
t.Fatal("expected no picker — newest match should be opened directly")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected an open command for the newest match")
|
||||
}
|
||||
msg, ok := cmd().(PipelineOpenPDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineOpenPDFMsg, got %T", cmd())
|
||||
}
|
||||
if !strings.HasSuffix(msg.Path, "cv-jane-doe-anthropic-2026-06-10.pdf") {
|
||||
t.Fatalf("expected newest PDF to be opened, got %q", msg.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateKeyFlashesWithoutManifestEntry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0, ReportNumber: "001"},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("D"))
|
||||
|
||||
if cmd != nil {
|
||||
t.Fatal("expected no command without a manifest entry")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected a flash notice without a manifest entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegenerateKeyEmitsGenerateMsgFromManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePDFFixture(t, root, "output/cv-jane-doe-globex.html")
|
||||
writePDFFixture(t, root, "data/pdf-index.tsv") // placeholder, overwritten below
|
||||
manifest := "001\toutput/cv-jane-doe-globex-2026-06-05.pdf\toutput/cv-jane-doe-globex.html\tletter\t2026-06-05\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "data", "pdf-index.tsv"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Globex", Role: "Engineer", Status: "Evaluated", Score: 4.0, ReportNumber: "001"},
|
||||
}
|
||||
|
||||
pm := newPDFTestModel(t, root, apps)
|
||||
updated, cmd := pm.Update(keyMsg("D"))
|
||||
|
||||
if cmd == nil {
|
||||
t.Fatal("expected a generate command")
|
||||
}
|
||||
if updated.flash == "" {
|
||||
t.Fatal("expected an in-progress flash while regenerating")
|
||||
}
|
||||
msg, ok := cmd().(PipelineGeneratePDFMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected PipelineGeneratePDFMsg, got %T", cmd())
|
||||
}
|
||||
if msg.ReportNumber != "001" || msg.HTMLPath != "output/cv-jane-doe-globex.html" || msg.Format != "letter" {
|
||||
t.Fatalf("unexpected generate request: %+v", msg)
|
||||
}
|
||||
|
||||
// Outcome message updates the flash.
|
||||
done, _ := updated.Update(PipelinePDFGeneratedMsg{Path: "/abs/cv.pdf"})
|
||||
if !strings.Contains(done.flash, "cv.pdf") {
|
||||
t.Fatalf("expected success flash to name the PDF, got %q", done.flash)
|
||||
}
|
||||
failed, _ := updated.Update(PipelinePDFGeneratedMsg{Err: "node not found"})
|
||||
if !strings.Contains(failed.flash, "node not found") {
|
||||
t.Fatalf("expected failure flash to carry the error, got %q", failed.flash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func tabIndexForFilter(t *testing.T, filter string) int {
|
||||
t.Helper()
|
||||
|
||||
for i, tab := range pipelineTabs {
|
||||
if tab.filter == filter {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected pipeline tabs to include filter %q", filter)
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestWithReloadedDataPreservesStateAndSelection(t *testing.T) {
|
||||
initialApps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Evaluated",
|
||||
Score: 4.2,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
initialApps,
|
||||
model.PipelineMetrics{Total: len(initialApps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.sortMode = sortCompany
|
||||
pm.activeTab = 0
|
||||
pm.viewMode = "flat"
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 1
|
||||
pm.reportCache["reports/002-beta.md"] = reportSummary{tldr: "cached"}
|
||||
|
||||
refreshedApps := []model.CareerApplication{
|
||||
initialApps[0],
|
||||
initialApps[1],
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Interview",
|
||||
Score: 4.8,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshedApps, model.PipelineMetrics{Total: len(refreshedApps)})
|
||||
|
||||
if reloaded.sortMode != sortCompany {
|
||||
t.Fatalf("expected sort mode %q, got %q", sortCompany, reloaded.sortMode)
|
||||
}
|
||||
if reloaded.viewMode != "flat" {
|
||||
t.Fatalf("expected view mode to stay flat, got %q", reloaded.viewMode)
|
||||
}
|
||||
if got := len(reloaded.filtered); got != 3 {
|
||||
t.Fatalf("expected 3 filtered apps after refresh, got %d", got)
|
||||
}
|
||||
if app, ok := reloaded.CurrentApp(); !ok || app.ReportPath != "reports/002-beta.md" {
|
||||
t.Fatalf("expected selection to stay on beta app, got %+v (ok=%v)", app, ok)
|
||||
}
|
||||
if reloaded.reportCache["reports/002-beta.md"].tldr != "cached" {
|
||||
t.Fatal("expected cached report summaries to survive refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAppLineIncludesDateColumn(t *testing.T) {
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
nil,
|
||||
model.PipelineMetrics{},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
|
||||
line := pm.renderAppLine(model.CareerApplication{
|
||||
Number: 42,
|
||||
Date: "2026-04-13",
|
||||
Company: "Anthropic",
|
||||
Role: "Forward Deployed Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.5,
|
||||
}, false)
|
||||
|
||||
if !strings.Contains(line, "2026-04-13") {
|
||||
t.Fatalf("expected rendered line to include date column, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, "#42") {
|
||||
t.Fatalf("expected rendered line to include tracker number marker, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchFiltersByCompanyRoleAndNotes(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6, Notes: "payments infra"},
|
||||
{Company: "Anthropic", Role: "AI Safety Engineer", Status: "Applied", Score: 4.8, Notes: "policy work"},
|
||||
{Company: "Acme Corp", Role: "Senior PM, Voice AI", Status: "Evaluated", Score: 4.2, Notes: "Series B in Madrid"},
|
||||
{Company: "Globex", Role: "Platform Engineer", Status: "Applied", Score: 3.9, Notes: "remote-first"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.activeTab = tabIndexForFilter(t, filterAll)
|
||||
|
||||
// Match by company substring (case-insensitive).
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected 1 match for 'stripe', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Match by role substring.
|
||||
pm.searchQuery = "voice ai"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Acme Corp" {
|
||||
t.Fatalf("expected 1 match for 'voice ai', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Match by notes substring.
|
||||
pm.searchQuery = "madrid"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Acme Corp" {
|
||||
t.Fatalf("expected 1 match for notes 'madrid', got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Empty query restores everything.
|
||||
pm.searchQuery = ""
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected empty query to restore all rows, got %d/%d", len(pm.filtered), len(apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchComposesWithActiveTab(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Stripe", Role: "Frontend Engineer", Status: "Applied", Score: 4.5},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Applied", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.activeTab = tabIndexForFilter(t, filterApplied)
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Role != "Frontend Engineer" {
|
||||
t.Fatalf("expected applied+stripe to leave only Frontend Engineer, got %+v", pm.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchIsCaseInsensitive(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
for _, q := range []string{"anthropic", "ANTHROPIC", "AnThRoPiC"} {
|
||||
pm.searchQuery = q
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 {
|
||||
t.Fatalf("expected case-insensitive match for %q, got %d rows", q, len(pm.filtered))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEnterCommitsAndEscClearsCommittedQuery(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
|
||||
// Open input and type "stripe".
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
if !pm.searchInput {
|
||||
t.Fatal("expected `/` to open search input")
|
||||
}
|
||||
for _, r := range "stripe" {
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
}
|
||||
if pm.searchQuery != "stripe" {
|
||||
t.Fatalf("expected query to live-update to 'stripe', got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected live filter to leave only Stripe, got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
// Enter commits — input closes, query stays.
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if pm.searchInput {
|
||||
t.Fatal("expected Enter to close input")
|
||||
}
|
||||
if pm.searchQuery != "stripe" {
|
||||
t.Fatalf("expected Enter to keep committed query, got %q", pm.searchQuery)
|
||||
}
|
||||
|
||||
// Esc on a committed query clears the search and restores the full list.
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("expected Esc to clear committed query, got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected Esc to restore full list, got %d/%d", len(pm.filtered), len(apps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEscInInputCancelsAndClears(t *testing.T) {
|
||||
// Use multiple rows so the test catches a regression where Esc clears the query
|
||||
// but forgets to re-apply the filter — the visible count would stay at 1
|
||||
// otherwise even though the underlying state went stale.
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Globex", Role: "Platform Engineer", Status: "Evaluated", Score: 4.0},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.searchInput = true
|
||||
pm.searchQuery = "stri"
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 {
|
||||
t.Fatalf("setup expected 1 row matching 'stri', got %d", len(pm.filtered))
|
||||
}
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if pm.searchInput {
|
||||
t.Fatal("expected Esc in input mode to close input")
|
||||
}
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("expected Esc in input mode to clear in-progress query, got %q", pm.searchQuery)
|
||||
}
|
||||
if len(pm.filtered) != len(apps) {
|
||||
t.Fatalf("expected Esc to re-expand filtered list to %d rows, got %d", len(apps), len(pm.filtered))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchResetsCursorOnQueryChange(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Acme", Role: "Backend Engineer", Status: "Evaluated", Score: 4.0},
|
||||
{Company: "Beta", Role: "Frontend Engineer", Status: "Evaluated", Score: 4.1},
|
||||
{Company: "Gamma", Role: "AI Engineer", Status: "Evaluated", Score: 4.2},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.cursor = 2
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
|
||||
|
||||
if pm.cursor != 0 {
|
||||
t.Fatalf("expected cursor to reset to 0 on query change, got %d", pm.cursor)
|
||||
}
|
||||
if pm.scrollOffset != 0 {
|
||||
t.Fatalf("expected scrollOffset to reset to 0 on query change, got %d", pm.scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchStatePreservedAcrossReload(t *testing.T) {
|
||||
initial := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend", Status: "Evaluated", Score: 4.6},
|
||||
{Company: "Acme", Role: "AI", Status: "Evaluated", Score: 4.0},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), initial, model.PipelineMetrics{Total: len(initial)}, "..", 120, 40)
|
||||
pm.searchQuery = "stripe"
|
||||
pm.applyFilterAndSort()
|
||||
|
||||
refreshed := append([]model.CareerApplication{}, initial...)
|
||||
refreshed = append(refreshed, model.CareerApplication{Company: "Globex", Role: "Platform", Status: "Applied", Score: 4.3})
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshed, model.PipelineMetrics{Total: len(refreshed)})
|
||||
|
||||
if reloaded.searchQuery != "stripe" {
|
||||
t.Fatalf("expected refresh to preserve search query, got %q", reloaded.searchQuery)
|
||||
}
|
||||
if len(reloaded.filtered) != 1 || reloaded.filtered[0].Company != "Stripe" {
|
||||
t.Fatalf("expected refresh+search to keep filter applied, got %+v", reloaded.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedAndDiscardedTabsFilterCorrectly(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Rejected",
|
||||
Score: 3.4,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Discarded",
|
||||
Score: 2.1,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
apps,
|
||||
model.PipelineMetrics{Total: len(apps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
|
||||
pm.activeTab = tabIndexForFilter(t, filterRejected)
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Status != "Rejected" {
|
||||
t.Fatalf("expected rejected tab to isolate rejected rows, got %+v", pm.filtered)
|
||||
}
|
||||
|
||||
pm.activeTab = tabIndexForFilter(t, filterDiscarded)
|
||||
pm.applyFilterAndSort()
|
||||
if len(pm.filtered) != 1 || pm.filtered[0].Status != "Discarded" {
|
||||
t.Fatalf("expected discarded tab to isolate discarded rows, got %+v", pm.filtered)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: with no committed search query, Esc must NOT close the screen.
|
||||
// The help bar advertises only `q quit`, so Esc quitting silently was a bug
|
||||
// that surfaced as accidental exits when users hit Esc to "back out" of the UI.
|
||||
func TestEscWithoutQueryIsNoOp(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
if pm.searchQuery != "" {
|
||||
t.Fatalf("setup expected empty search query, got %q", pm.searchQuery)
|
||||
}
|
||||
|
||||
pm, cmd := pm.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if cmd != nil {
|
||||
// PipelineClosedMsg used to fire here; ensure it doesn't anymore.
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineClosedMsg); ok {
|
||||
t.Fatalf("expected Esc with no query to be a no-op, got PipelineClosedMsg")
|
||||
}
|
||||
t.Fatalf("expected Esc with no query to return nil cmd, got %T", msg)
|
||||
}
|
||||
}
|
||||
if pm.searchInput {
|
||||
t.Fatal("Esc with no query should not toggle searchInput")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: typing during search input must not synchronously fan out to
|
||||
// loadCurrentReport. Reading reports per keystroke caused visible UI lag, so
|
||||
// the load is deferred to commit (Enter) / cancel (Esc) instead.
|
||||
func TestSearchTypingDoesNotLoadReports(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "Stripe", Role: "Backend Engineer", Status: "Evaluated", Score: 4.6, ReportPath: "reports/001-stripe.md"},
|
||||
{Company: "Anthropic", Role: "AI Engineer", Status: "Evaluated", Score: 4.8, ReportPath: "reports/002-anthropic.md"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
|
||||
pm, _ = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||
if !pm.searchInput {
|
||||
t.Fatal("expected `/` to open search input")
|
||||
}
|
||||
|
||||
// Typing must not trigger PipelineLoadReportMsg.
|
||||
for _, r := range "stri" {
|
||||
var cmd tea.Cmd
|
||||
pm, cmd = pm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatalf("typing rune %q should not emit PipelineLoadReportMsg", string(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace must not trigger PipelineLoadReportMsg either.
|
||||
pm, cmd := pm.Update(tea.KeyMsg{Type: tea.KeyBackspace})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatal("Backspace during search input should not emit PipelineLoadReportMsg")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+U must not trigger PipelineLoadReportMsg either.
|
||||
pm, cmd = pm.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
if cmd != nil {
|
||||
if msg := cmd(); msg != nil {
|
||||
if _, ok := msg.(PipelineLoadReportMsg); ok {
|
||||
t.Fatal("Ctrl+U during search input should not emit PipelineLoadReportMsg")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previewModelWith(t *testing.T, app model.CareerApplication) PipelineModel {
|
||||
t.Helper()
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
[]model.CareerApplication{app},
|
||||
model.PipelineMetrics{Total: 1},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 0
|
||||
return pm
|
||||
}
|
||||
|
||||
func TestPreviewKeepsDiscardReasonWhenTlDrIsCached(t *testing.T) {
|
||||
app := model.CareerApplication{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Descartado 2026-03-12",
|
||||
Notes: "took too long to respond",
|
||||
ReportPath: "reports/001-acme.md",
|
||||
}
|
||||
pm := previewModelWith(t, app)
|
||||
pm.reportCache[app.ReportPath] = reportSummary{tldr: "great team, fast pace"}
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "great team, fast pace") {
|
||||
t.Fatalf("expected preview to keep the cached TL;DR, got %q", preview)
|
||||
}
|
||||
// Regression for #787: before the Outcome line, a cached TL;DR replaced the
|
||||
// notes entirely and the discard reason disappeared from the preview.
|
||||
if !strings.Contains(preview, "took too long to respond") {
|
||||
t.Fatalf("expected preview to keep the discard reason alongside the TL;DR, got %q", preview)
|
||||
}
|
||||
if !strings.Contains(preview, "Descartado 2026-03-12") {
|
||||
t.Fatalf("expected preview to show the closing status, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeShownWithoutReportSummary(t *testing.T) {
|
||||
pm := previewModelWith(t, model.CareerApplication{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "SKIP",
|
||||
Notes: "geo blocker",
|
||||
})
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "Outcome:") || !strings.Contains(preview, "geo blocker") {
|
||||
t.Fatalf("expected outcome line with notes for skipped app, got %q", preview)
|
||||
}
|
||||
if strings.Count(preview, "geo blocker") != 1 {
|
||||
t.Fatalf("expected notes to appear exactly once, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeOmittedForActiveApps(t *testing.T) {
|
||||
app := model.CareerApplication{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied 2026-04-01",
|
||||
Notes: "warm intro via referral",
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
}
|
||||
pm := previewModelWith(t, app)
|
||||
pm.reportCache[app.ReportPath] = reportSummary{tldr: "strong fit"}
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if strings.Contains(preview, "Outcome:") {
|
||||
t.Fatalf("expected no outcome line for an active app, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewOutcomeForStatusWithoutNotes(t *testing.T) {
|
||||
pm := previewModelWith(t, model.CareerApplication{
|
||||
Company: "Delta",
|
||||
Role: "SRE",
|
||||
Status: "**Rejected** 2026-05-02",
|
||||
})
|
||||
|
||||
preview := pm.renderPreview()
|
||||
|
||||
if !strings.Contains(preview, "Rejected 2026-05-02") {
|
||||
t.Fatalf("expected outcome to show the bare closing status, got %q", preview)
|
||||
}
|
||||
if strings.Contains(preview, "Loading preview...") {
|
||||
t.Fatalf("expected outcome line to replace the loading placeholder, got %q", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReloadedDataPreservesCursorWhenAppRemoved(t *testing.T) {
|
||||
initialApps := []model.CareerApplication{
|
||||
{
|
||||
Company: "Acme",
|
||||
Role: "Backend Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.2,
|
||||
ReportPath: "reports/001-acme.md",
|
||||
},
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
{
|
||||
Company: "Gamma",
|
||||
Role: "AI Engineer",
|
||||
Status: "Applied",
|
||||
Score: 4.8,
|
||||
ReportPath: "reports/003-gamma.md",
|
||||
},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(
|
||||
theme.NewTheme("catppuccin-mocha"),
|
||||
initialApps,
|
||||
model.PipelineMetrics{Total: len(initialApps)},
|
||||
"..",
|
||||
120,
|
||||
40,
|
||||
)
|
||||
pm.activeTab = tabIndexForFilter(t, filterApplied)
|
||||
pm.applyFilterAndSort()
|
||||
pm.cursor = 1
|
||||
|
||||
refreshedApps := []model.CareerApplication{
|
||||
initialApps[0],
|
||||
{
|
||||
Company: "Beta",
|
||||
Role: "Platform Engineer",
|
||||
Status: "Rejected", // Changed!
|
||||
Score: 4.6,
|
||||
ReportPath: "reports/002-beta.md",
|
||||
},
|
||||
initialApps[2],
|
||||
}
|
||||
|
||||
reloaded := pm.WithReloadedData(refreshedApps, model.PipelineMetrics{Total: len(refreshedApps)})
|
||||
|
||||
if got := len(reloaded.filtered); got != 2 {
|
||||
t.Fatalf("expected 2 filtered apps after refresh, got %d", got)
|
||||
}
|
||||
if reloaded.cursor < 0 || reloaded.cursor >= len(reloaded.filtered) {
|
||||
t.Fatalf("expected cursor to be within [0, %d], got %d", len(reloaded.filtered)-1, reloaded.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
// ProgressClosedMsg is emitted when the progress screen is dismissed.
|
||||
type ProgressClosedMsg struct{}
|
||||
|
||||
// ProgressModel implements the progress analytics screen.
|
||||
type ProgressModel struct {
|
||||
metrics model.ProgressMetrics
|
||||
scrollOffset int
|
||||
width int
|
||||
height int
|
||||
theme theme.Theme
|
||||
}
|
||||
|
||||
// NewProgressModel creates a new progress screen.
|
||||
func NewProgressModel(t theme.Theme, metrics model.ProgressMetrics, width, height int) ProgressModel {
|
||||
return ProgressModel{
|
||||
metrics: metrics,
|
||||
width: width,
|
||||
height: height,
|
||||
theme: t,
|
||||
}
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m ProgressModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resize updates dimensions.
|
||||
func (m *ProgressModel) Resize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
}
|
||||
|
||||
// Update handles input for the progress screen.
|
||||
func (m ProgressModel) Update(msg tea.Msg) (ProgressModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
return m, func() tea.Msg { return ProgressClosedMsg{} }
|
||||
case "down", "j":
|
||||
m.scrollOffset++
|
||||
case "up", "k":
|
||||
if m.scrollOffset > 0 {
|
||||
m.scrollOffset--
|
||||
}
|
||||
case "pgdown", "ctrl+d":
|
||||
m.scrollOffset += m.height / 2
|
||||
case "pgup", "ctrl+u":
|
||||
m.scrollOffset -= m.height / 2
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the progress screen.
|
||||
func (m ProgressModel) View() string {
|
||||
header := m.renderHeader()
|
||||
funnel := m.renderFunnel()
|
||||
scores := m.renderScoreDistribution()
|
||||
rates := m.renderRates()
|
||||
weekly := m.renderWeeklyActivity()
|
||||
help := m.renderHelp()
|
||||
|
||||
// Combine panels
|
||||
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||
funnel,
|
||||
"",
|
||||
scores,
|
||||
"",
|
||||
rates,
|
||||
"",
|
||||
weekly,
|
||||
)
|
||||
|
||||
// Apply scroll
|
||||
bodyLines := strings.Split(body, "\n")
|
||||
offset := m.scrollOffset
|
||||
if offset >= len(bodyLines) {
|
||||
offset = len(bodyLines) - 1
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > 0 {
|
||||
bodyLines = bodyLines[offset:]
|
||||
}
|
||||
|
||||
// Clamp to available height
|
||||
availHeight := m.height - 4 // header + help + padding
|
||||
if availHeight < 3 {
|
||||
availHeight = 3
|
||||
}
|
||||
if len(bodyLines) > availHeight {
|
||||
bodyLines = bodyLines[:availHeight]
|
||||
}
|
||||
|
||||
body = strings.Join(bodyLines, "\n")
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, help)
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderHeader() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(m.theme.Text).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 2)
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Render("SEARCH PROGRESS")
|
||||
|
||||
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
total := len(m.metrics.FunnelStages)
|
||||
totalCount := 0
|
||||
if total > 0 {
|
||||
totalCount = m.metrics.FunnelStages[0].Count
|
||||
}
|
||||
info := right.Render(fmt.Sprintf("%d evaluated | %.1f avg score", totalCount, m.metrics.AvgScore))
|
||||
|
||||
gap := m.width - lipgloss.Width(title) - lipgloss.Width(info) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(title + strings.Repeat(" ", gap) + info)
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderFunnel() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Pipeline Funnel")))
|
||||
|
||||
if len(m.metrics.FunnelStages) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, s := range m.metrics.FunnelStages {
|
||||
if s.Count > maxCount {
|
||||
maxCount = s.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 10
|
||||
barMaxW := m.width - labelW - 20 // room for label, count, pct
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
// Colors for funnel stages (gradient from cool to warm)
|
||||
stageColors := []lipgloss.Color{
|
||||
m.theme.Blue,
|
||||
m.theme.Sky,
|
||||
m.theme.Green,
|
||||
m.theme.Yellow,
|
||||
m.theme.Peach,
|
||||
}
|
||||
|
||||
for i, stage := range m.metrics.FunnelStages {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = stage.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && stage.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
color := m.theme.Text
|
||||
if i < len(stageColors) {
|
||||
color = stageColors[i]
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(color)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(stage.Label)
|
||||
|
||||
pctStr := ""
|
||||
if i > 0 {
|
||||
pctStr = fmt.Sprintf(" (%.0f%%)", stage.Pct)
|
||||
}
|
||||
count := countStyle.Render(fmt.Sprintf(" %d%s", stage.Count, pctStr))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderScoreDistribution() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Score Distribution")))
|
||||
|
||||
if len(m.metrics.ScoreBuckets) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, b := range m.metrics.ScoreBuckets {
|
||||
if b.Count > maxCount {
|
||||
maxCount = b.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 8
|
||||
barMaxW := m.width - labelW - 14
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
// Colors for score ranges (green to red)
|
||||
bucketColors := []lipgloss.Color{
|
||||
m.theme.Green,
|
||||
m.theme.Green,
|
||||
m.theme.Yellow,
|
||||
m.theme.Peach,
|
||||
m.theme.Red,
|
||||
}
|
||||
|
||||
for i, bucket := range m.metrics.ScoreBuckets {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = bucket.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && bucket.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
color := m.theme.Text
|
||||
if i < len(bucketColors) {
|
||||
color = bucketColors[i]
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(color)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(bucket.Label)
|
||||
count := countStyle.Render(fmt.Sprintf(" %d", bucket.Count))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderRates() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Conversion Rates")))
|
||||
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Text)
|
||||
valueStyle := lipgloss.NewStyle().Bold(true)
|
||||
sepStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
||||
|
||||
responseColor := m.rateColor(m.metrics.ResponseRate)
|
||||
interviewColor := m.rateColor(m.metrics.InterviewRate)
|
||||
offerColor := m.rateColor(m.metrics.OfferRate)
|
||||
|
||||
sep := sepStyle.Render(" | ")
|
||||
|
||||
rates := labelStyle.Render("Response Rate: ") +
|
||||
valueStyle.Foreground(responseColor).Render(fmt.Sprintf("%.1f%%", m.metrics.ResponseRate)) +
|
||||
sep +
|
||||
labelStyle.Render("Interview Rate: ") +
|
||||
valueStyle.Foreground(interviewColor).Render(fmt.Sprintf("%.1f%%", m.metrics.InterviewRate)) +
|
||||
sep +
|
||||
labelStyle.Render("Offer Rate: ") +
|
||||
valueStyle.Foreground(offerColor).Render(fmt.Sprintf("%.1f%%", m.metrics.OfferRate))
|
||||
|
||||
lines = append(lines, padStyle.Render(rates))
|
||||
|
||||
// Active summary
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
activeInfo := dimStyle.Render(fmt.Sprintf(
|
||||
"%d active applications | %d total offers",
|
||||
m.metrics.ActiveApps, m.metrics.TotalOffers,
|
||||
))
|
||||
lines = append(lines, padStyle.Render(activeInfo))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderWeeklyActivity() string {
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
sectionTitle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, padStyle.Render(sectionTitle.Render("Weekly Activity")))
|
||||
|
||||
if len(m.metrics.WeeklyActivity) == 0 {
|
||||
dimStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
lines = append(lines, padStyle.Render(dimStyle.Render("No data")))
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// Find max count for bar scaling
|
||||
maxCount := 0
|
||||
for _, w := range m.metrics.WeeklyActivity {
|
||||
if w.Count > maxCount {
|
||||
maxCount = w.Count
|
||||
}
|
||||
}
|
||||
|
||||
labelW := 10
|
||||
barMaxW := m.width - labelW - 12
|
||||
if barMaxW < 10 {
|
||||
barMaxW = 10
|
||||
}
|
||||
|
||||
for _, week := range m.metrics.WeeklyActivity {
|
||||
barW := 0
|
||||
if maxCount > 0 {
|
||||
barW = week.Count * barMaxW / maxCount
|
||||
}
|
||||
if barW < 1 && week.Count > 0 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
barStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
||||
labelStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Width(labelW)
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
// Show short week label (e.g., "W14" from "2026-W14")
|
||||
shortWeek := week.Week
|
||||
if idx := strings.Index(shortWeek, "-"); idx >= 0 {
|
||||
shortWeek = shortWeek[idx+1:]
|
||||
}
|
||||
|
||||
bar := barStyle.Render(strings.Repeat("\u2588", barW))
|
||||
label := labelStyle.Render(shortWeek)
|
||||
count := countStyle.Render(fmt.Sprintf(" %d", week.Count))
|
||||
|
||||
lines = append(lines, padStyle.Render(label+bar+count))
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m ProgressModel) renderHelp() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Subtext).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 1)
|
||||
|
||||
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
||||
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
brand := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("career-ops by santifer.io")
|
||||
|
||||
keys := keyStyle.Render("\u2191\u2193") + descStyle.Render(" scroll ") +
|
||||
keyStyle.Render("PgUp/Dn") + descStyle.Render(" page ") +
|
||||
keyStyle.Render("Esc") + descStyle.Render(" back")
|
||||
|
||||
gap := m.width - lipgloss.Width(keys) - lipgloss.Width(brand) - 2
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(keys + strings.Repeat(" ", gap) + brand)
|
||||
}
|
||||
|
||||
// rateColor returns a color based on the rate value.
|
||||
func (m ProgressModel) rateColor(rate float64) lipgloss.Color {
|
||||
switch {
|
||||
case rate >= 30:
|
||||
return m.theme.Green
|
||||
case rate >= 15:
|
||||
return m.theme.Yellow
|
||||
case rate >= 5:
|
||||
return m.theme.Peach
|
||||
default:
|
||||
return m.theme.Red
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func TestSortCycleIncludesNewColumns(t *testing.T) {
|
||||
want := map[string]bool{sortLocation: false, sortPay: false, sortLast: false}
|
||||
for _, s := range sortCycle {
|
||||
if _, ok := want[s]; ok {
|
||||
want[s] = true
|
||||
}
|
||||
}
|
||||
for mode, found := range want {
|
||||
if !found {
|
||||
t.Errorf("sort cycle is missing %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByPayLocationAndLastContact(t *testing.T) {
|
||||
apps := []model.CareerApplication{
|
||||
{Company: "LowPay", Status: "Applied", PayMax: 150_000, WorkMode: "Full", Location: "Austin, TX", LastContact: "2026-06-01"},
|
||||
{Company: "NoPay", Status: "Applied", PayMax: 0, WorkMode: "", LastContact: ""},
|
||||
{Company: "HighPay", Status: "Applied", PayMax: 250_000, WorkMode: "Hybrid", Location: "Charlotte, NC", LastContact: "2026-06-05"},
|
||||
{Company: "MidPay", Status: "Applied", PayMax: 200_000, WorkMode: "Remote", LastContact: "2026-06-03"},
|
||||
}
|
||||
|
||||
pm := NewPipelineModel(theme.NewTheme("catppuccin-mocha"), apps, model.PipelineMetrics{Total: len(apps)}, "..", 120, 40)
|
||||
pm.viewMode = "flat"
|
||||
|
||||
pm.sortMode = sortPay
|
||||
pm.applyFilterAndSort()
|
||||
if pm.filtered[0].Company != "HighPay" || pm.filtered[len(pm.filtered)-1].Company != "NoPay" {
|
||||
t.Fatalf("pay sort: expected HighPay first and NoPay last, got %s..%s",
|
||||
pm.filtered[0].Company, pm.filtered[len(pm.filtered)-1].Company)
|
||||
}
|
||||
|
||||
pm.sortMode = sortLocation
|
||||
pm.applyFilterAndSort()
|
||||
// Remote-first ordering: Remote, Hybrid, Full, unknown.
|
||||
wantOrder := []string{"MidPay", "HighPay", "LowPay", "NoPay"}
|
||||
for i, w := range wantOrder {
|
||||
if pm.filtered[i].Company != w {
|
||||
t.Fatalf("location sort: position %d = %s, want %s", i, pm.filtered[i].Company, w)
|
||||
}
|
||||
}
|
||||
|
||||
pm.sortMode = sortLast
|
||||
pm.applyFilterAndSort()
|
||||
if pm.filtered[0].Company != "HighPay" || pm.filtered[len(pm.filtered)-1].Company != "NoPay" {
|
||||
t.Fatalf("last-contact sort: expected HighPay first and NoPay last, got %s..%s",
|
||||
pm.filtered[0].Company, pm.filtered[len(pm.filtered)-1].Company)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFormatTimeAgo(t *testing.T) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
threeDaysAgo := time.Now().AddDate(0, 0, -3).Format("2006-01-02")
|
||||
|
||||
// Day-granular dates never fabricate sub-day hours: same day is "today".
|
||||
if got := formatTimeAgo(today); got != "today" {
|
||||
t.Errorf("today should render as \"today\", got %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(yesterday); got != "yesterday" {
|
||||
t.Errorf("one day ago should render as \"yesterday\", got %q", got)
|
||||
}
|
||||
if got := formatTimeAgo(threeDaysAgo); got != "3d ago" {
|
||||
t.Errorf("three days ago should render as \"3d ago\", got %q", got)
|
||||
}
|
||||
|
||||
// Future dates clamp to "today" instead of going negative.
|
||||
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
|
||||
if got := formatTimeAgo(tomorrow); got != "today" {
|
||||
t.Errorf("future date should clamp to \"today\", got %q", got)
|
||||
}
|
||||
|
||||
// Non-dates pass through untouched.
|
||||
if got := formatTimeAgo("—"); got != "—" {
|
||||
t.Errorf("non-date should pass through, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/data"
|
||||
"github.com/santifer/career-ops/dashboard/internal/model"
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
// ViewerClosedMsg is emitted when the viewer is dismissed.
|
||||
type ViewerClosedMsg struct{}
|
||||
|
||||
// ViewerOpenCoverLetterMsg is emitted when the user requests to open the cover letter PDF.
|
||||
type ViewerOpenCoverLetterMsg struct{ Path string }
|
||||
|
||||
// ViewerUpdateStatusMsg is emitted when a status update is requested from the viewer.
|
||||
type ViewerUpdateStatusMsg struct {
|
||||
App model.CareerApplication
|
||||
NewStatus string
|
||||
}
|
||||
|
||||
// ViewerModel implements an integrated file viewer screen.
|
||||
type ViewerModel struct {
|
||||
lines []string
|
||||
renderedLines []string
|
||||
title string
|
||||
scrollOffset int
|
||||
width int
|
||||
height int
|
||||
theme theme.Theme
|
||||
app model.CareerApplication
|
||||
careerOpsPath string
|
||||
coverLetterPath string
|
||||
statusPicker bool
|
||||
statusCursor int
|
||||
}
|
||||
|
||||
// NewViewerModel creates a new file viewer for the given path.
|
||||
func NewViewerModel(t theme.Theme, careerOpsPath, path, title string, width, height int, app model.CareerApplication) ViewerModel {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
content = []byte("Error reading file: " + err.Error())
|
||||
}
|
||||
|
||||
var lines []string
|
||||
if len(content) > 0 {
|
||||
lines = strings.Split(string(content), "\n")
|
||||
}
|
||||
|
||||
m := ViewerModel{
|
||||
lines: lines,
|
||||
title: title,
|
||||
width: width,
|
||||
height: height,
|
||||
theme: t,
|
||||
app: app,
|
||||
careerOpsPath: careerOpsPath,
|
||||
coverLetterPath: parseCoverLetterPath(lines, careerOpsPath),
|
||||
}
|
||||
m.rebuildRender()
|
||||
return m
|
||||
}
|
||||
|
||||
// parseCoverLetterPath scans the report lines for a "PDF generated: output/..." line
|
||||
// inside a "## Cover Letter Draft" section and returns the relative path if the file exists.
|
||||
func parseCoverLetterPath(lines []string, careerOpsPath string) string {
|
||||
inCoverSection := false
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "## Cover Letter Draft") {
|
||||
inCoverSection = true
|
||||
continue
|
||||
}
|
||||
if inCoverSection && strings.HasPrefix(trimmed, "## ") {
|
||||
break
|
||||
}
|
||||
if inCoverSection {
|
||||
if m := reCoverLetterPDF.FindStringSubmatch(line); m != nil {
|
||||
relPath := m[1]
|
||||
abs := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
return relPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rebuildRender recomputes renderedLines from raw lines using the current width.
|
||||
func (m *ViewerModel) rebuildRender() {
|
||||
m.renderedLines = m.renderAll()
|
||||
m.clampScrollOffset()
|
||||
}
|
||||
|
||||
func (m *ViewerModel) clampScrollOffset() {
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset > maxScroll {
|
||||
m.scrollOffset = maxScroll
|
||||
}
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m ViewerModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ViewerModel) Resize(width, height int) {
|
||||
m.width = width
|
||||
m.height = height
|
||||
m.rebuildRender()
|
||||
}
|
||||
|
||||
func (m ViewerModel) Update(msg tea.Msg) (ViewerModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.statusPicker {
|
||||
return m.handleStatusPicker(msg)
|
||||
}
|
||||
switch msg.String() {
|
||||
case "q", "esc":
|
||||
return m, func() tea.Msg { return ViewerClosedMsg{} }
|
||||
|
||||
case "c":
|
||||
m.statusPicker = true
|
||||
m.statusCursor = 0
|
||||
currentNorm := data.NormalizeStatus(m.app.Status)
|
||||
for idx, opt := range statusOptions {
|
||||
if data.NormalizeStatus(opt) == currentNorm {
|
||||
m.statusCursor = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
m.clampScrollOffset()
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset < maxScroll {
|
||||
m.scrollOffset++
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
if m.scrollOffset > 0 {
|
||||
m.scrollOffset--
|
||||
}
|
||||
|
||||
case "pgdown", "ctrl+d":
|
||||
jump := m.bodyHeight() / 2
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
m.scrollOffset += jump
|
||||
if m.scrollOffset > maxScroll {
|
||||
m.scrollOffset = maxScroll
|
||||
}
|
||||
|
||||
case "pgup", "ctrl+u":
|
||||
jump := m.bodyHeight() / 2
|
||||
m.scrollOffset -= jump
|
||||
if m.scrollOffset < 0 {
|
||||
m.scrollOffset = 0
|
||||
}
|
||||
|
||||
case "home", "g":
|
||||
m.scrollOffset = 0
|
||||
|
||||
case "end", "G":
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
m.scrollOffset = maxScroll
|
||||
|
||||
case "L":
|
||||
if m.coverLetterPath != "" {
|
||||
fullPath := filepath.Join(m.careerOpsPath, filepath.FromSlash(m.coverLetterPath))
|
||||
return m, func() tea.Msg { return ViewerOpenCoverLetterMsg{Path: fullPath} }
|
||||
}
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.rebuildRender()
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ViewerModel) bodyHeight() int {
|
||||
h := m.height - 4 // header + footer + padding
|
||||
if m.statusPicker {
|
||||
h -= (len(statusOptions) + 1)
|
||||
}
|
||||
if h < 3 {
|
||||
h = 3
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (m ViewerModel) View() string {
|
||||
header := m.renderHeader()
|
||||
body := m.renderBody()
|
||||
if m.statusPicker {
|
||||
body = m.overlayStatusPicker(body)
|
||||
}
|
||||
footer := m.renderFooter()
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderHeader() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(m.theme.Text).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 2)
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Render(m.title)
|
||||
|
||||
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
scroll := right.Render(func() string {
|
||||
if len(m.renderedLines) == 0 {
|
||||
return ""
|
||||
}
|
||||
pct := 0
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll > 0 {
|
||||
pct = m.scrollOffset * 100 / maxScroll
|
||||
}
|
||||
if m.scrollOffset == 0 {
|
||||
return "Top"
|
||||
}
|
||||
if m.scrollOffset >= maxScroll {
|
||||
return "End"
|
||||
}
|
||||
return func() string {
|
||||
s := pct
|
||||
return string(rune('0'+s/10%10)) + string(rune('0'+s%10)) + "%"
|
||||
}()
|
||||
}())
|
||||
|
||||
gap := m.width - lipgloss.Width(m.title) - lipgloss.Width(scroll) - 4
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return style.Render(title + strings.Repeat(" ", gap) + scroll)
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderBody() string {
|
||||
bh := m.bodyHeight()
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
|
||||
if len(m.renderedLines) == 0 {
|
||||
emptyStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
return padStyle.Render(emptyStyle.Render("(empty file)"))
|
||||
}
|
||||
|
||||
end := m.scrollOffset + bh
|
||||
if end > len(m.renderedLines) {
|
||||
end = len(m.renderedLines)
|
||||
}
|
||||
visible := m.renderedLines[m.scrollOffset:end]
|
||||
|
||||
flat := make([]string, bh)
|
||||
copy(flat, visible)
|
||||
|
||||
return padStyle.Render(strings.Join(flat, "\n"))
|
||||
}
|
||||
|
||||
// renderAll converts every raw markdown line into visual terminal lines.
|
||||
func (m ViewerModel) renderAll() []string {
|
||||
var styled []string
|
||||
i := 0
|
||||
for i < len(m.lines) {
|
||||
line := m.lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if trimmed == "" {
|
||||
styled = append(styled, "")
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if isTableLine(line) {
|
||||
tableStart := i
|
||||
for i < len(m.lines) && isTableLine(m.lines[i]) {
|
||||
i++
|
||||
}
|
||||
styled = append(styled, m.renderTableBlock(m.lines[tableStart:i])...)
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
i++
|
||||
var codeLines []string
|
||||
for i < len(m.lines) {
|
||||
if strings.TrimSpace(m.lines[i]) == "```" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
codeLines = append(codeLines, m.lines[i])
|
||||
i++
|
||||
}
|
||||
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
for _, cl := range codeLines {
|
||||
for _, wl := range strings.Split(ansi.Wrap(" "+cl, w, ""), "\n") {
|
||||
styled = append(styled, codeStyle.Render(wl))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if isSpecialBlockLine(trimmed) {
|
||||
styled = append(styled, m.styleLine(line))
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
start := i
|
||||
for i < len(m.lines) {
|
||||
next := strings.TrimSpace(m.lines[i])
|
||||
if next == "" || isSpecialBlockLine(next) {
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i > start {
|
||||
paraLines := m.lines[start:i]
|
||||
para := strings.Join(paraLines, " ")
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
wrapped := m.wrapParagraph(m.renderInlineElements(para), w)
|
||||
for _, wl := range wrapped {
|
||||
styled = append(styled, wl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var flat []string
|
||||
for _, s := range styled {
|
||||
if strings.IndexByte(s, '\n') >= 0 {
|
||||
flat = append(flat, strings.Split(s, "\n")...)
|
||||
} else {
|
||||
flat = append(flat, s)
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
func isTableLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return len(trimmed) > 1 && trimmed[0] == '|'
|
||||
}
|
||||
|
||||
// isTableSeparator checks if a line is a table separator (|---|---|).
|
||||
func isTableSeparator(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "|") {
|
||||
return false
|
||||
}
|
||||
cleaned := strings.NewReplacer("|", "", "-", "", ":", "", " ", "").Replace(trimmed)
|
||||
return cleaned == ""
|
||||
}
|
||||
|
||||
// parseTableCells splits a table line into trimmed cells.
|
||||
func parseTableCells(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Remove leading and trailing pipes
|
||||
if len(trimmed) > 0 && trimmed[0] == '|' {
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '|' {
|
||||
trimmed = trimmed[:len(trimmed)-1]
|
||||
}
|
||||
parts := strings.Split(trimmed, "|")
|
||||
cells := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
cells[i] = strings.TrimSpace(p)
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func detectAlignment(sep string) lipgloss.Position {
|
||||
s := strings.TrimSpace(sep)
|
||||
if strings.HasPrefix(s, ":") && strings.HasSuffix(s, ":") {
|
||||
return lipgloss.Center
|
||||
}
|
||||
if strings.HasSuffix(s, ":") {
|
||||
return lipgloss.Right
|
||||
}
|
||||
return lipgloss.Left
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderTableBlock(lines []string) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var headers []string
|
||||
var dataRows [][]string
|
||||
var alignments []lipgloss.Position
|
||||
|
||||
for _, line := range lines {
|
||||
if isTableSeparator(line) {
|
||||
if len(alignments) == 0 {
|
||||
for _, cell := range parseTableCells(line) {
|
||||
alignments = append(alignments, detectAlignment(cell))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
cells := parseTableCells(line)
|
||||
rendered := make([]string, len(cells))
|
||||
for i, c := range cells {
|
||||
rendered[i] = m.renderInlineElements(c)
|
||||
}
|
||||
if headers == nil {
|
||||
headers = rendered
|
||||
} else {
|
||||
dataRows = append(dataRows, rendered)
|
||||
}
|
||||
}
|
||||
|
||||
if len(headers) == 0 {
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
result = append(result, m.styleLine(line))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
|
||||
borderStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
||||
t := table.New().
|
||||
Width(w).
|
||||
Wrap(true).
|
||||
BorderStyle(borderStyle).
|
||||
BorderTop(true).BorderBottom(true).
|
||||
BorderLeft(true).BorderRight(true).
|
||||
BorderHeader(true).BorderColumn(true)
|
||||
|
||||
t.Headers(headers...)
|
||||
if len(dataRows) > 0 {
|
||||
t.Rows(dataRows...)
|
||||
}
|
||||
|
||||
t.StyleFunc(func(row, col int) lipgloss.Style {
|
||||
st := lipgloss.NewStyle().Padding(0, 1)
|
||||
if row == table.HeaderRow {
|
||||
return st.Bold(true).Foreground(m.theme.Sky)
|
||||
}
|
||||
if col < len(alignments) {
|
||||
st = st.Align(alignments[col])
|
||||
}
|
||||
return st.Foreground(m.theme.Text)
|
||||
})
|
||||
|
||||
return strings.Split(t.String(), "\n")
|
||||
}
|
||||
|
||||
var (
|
||||
reBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
reBareURL = regexp.MustCompile(`https?://\S*[^\s\)\]\.,;:!?]`)
|
||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
reListNumber = regexp.MustCompile(`^(\s*\d+\.\s+)(.*)$`)
|
||||
reCoverLetterPDF = regexp.MustCompile(`PDF generated:\s*(output/[^\s]+\.pdf)`)
|
||||
reRelPDFPath = regexp.MustCompile(`output/cv-[^\s\)\]\.,;:!?"']+\.pdf`)
|
||||
)
|
||||
|
||||
func isHeadingLine(line string) bool {
|
||||
return strings.HasPrefix(line, "# ") ||
|
||||
strings.HasPrefix(line, "## ") ||
|
||||
strings.HasPrefix(line, "### ") ||
|
||||
strings.HasPrefix(line, "#### ") ||
|
||||
strings.HasPrefix(line, "##### ") ||
|
||||
strings.HasPrefix(line, "###### ")
|
||||
}
|
||||
|
||||
func isSpecialBlockLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
return isHeadingLine(trimmed) ||
|
||||
trimmed == "---" || trimmed == "***" ||
|
||||
strings.HasPrefix(trimmed, "> ") ||
|
||||
strings.HasPrefix(trimmed, "|") ||
|
||||
strings.HasPrefix(trimmed, "```") ||
|
||||
strings.HasPrefix(trimmed, "- ") ||
|
||||
strings.HasPrefix(trimmed, "* ") ||
|
||||
reListNumber.MatchString(trimmed) ||
|
||||
(strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**"))
|
||||
}
|
||||
|
||||
func (m ViewerModel) wrapParagraph(text string, width int) []string {
|
||||
if width <= 0 {
|
||||
return []string{text}
|
||||
}
|
||||
wrapped := ansi.Wrap(text, width, "")
|
||||
return strings.Split(wrapped, "\n")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderInlineElements(line string) string {
|
||||
return m.renderInlineElementsAs(line, m.theme.Subtext)
|
||||
}
|
||||
|
||||
// renderInlineElementsAs walks the raw line once and reapplies baseColor around
|
||||
// every plain-text span, so resets emitted by inline tokens (code, bold, link,
|
||||
// bare URL) don't leak through to subsequent text.
|
||||
func (m ViewerModel) renderInlineElementsAs(line string, baseColor lipgloss.Color) string {
|
||||
baseStyle := lipgloss.NewStyle().Foreground(baseColor)
|
||||
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
||||
boldStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Yellow)
|
||||
linkStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
||||
|
||||
var b strings.Builder
|
||||
rest := line
|
||||
for rest != "" {
|
||||
match := findInlineMatch(rest, codeStyle, boldStyle, linkStyle, m.careerOpsPath)
|
||||
if match == nil {
|
||||
b.WriteString(baseStyle.Render(rest))
|
||||
break
|
||||
}
|
||||
if match.start > 0 {
|
||||
b.WriteString(baseStyle.Render(rest[:match.start]))
|
||||
}
|
||||
b.WriteString(match.rendered)
|
||||
rest = rest[match.end:]
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type inlineMatch struct {
|
||||
start, end int
|
||||
rendered string
|
||||
}
|
||||
|
||||
func findInlineMatch(s string, codeStyle, boldStyle, linkStyle lipgloss.Style, careerOpsPath string) *inlineMatch {
|
||||
var best *inlineMatch
|
||||
consider := func(loc []int, rendered func() string) {
|
||||
if loc == nil || (best != nil && loc[0] >= best.start) {
|
||||
return
|
||||
}
|
||||
best = &inlineMatch{start: loc[0], end: loc[1], rendered: rendered()}
|
||||
}
|
||||
|
||||
if loc := reInlineCode.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return codeStyle.Render(s[loc[0]+1 : loc[1]-1]) })
|
||||
}
|
||||
if loc := reBold.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return boldStyle.Render(s[loc[0]+2 : loc[1]-2]) })
|
||||
}
|
||||
if loc := reLink.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string {
|
||||
sm := reLink.FindStringSubmatch(s[loc[0]:loc[1]])
|
||||
if len(sm) >= 2 {
|
||||
return linkStyle.Render(sm[1])
|
||||
}
|
||||
return s[loc[0]:loc[1]]
|
||||
})
|
||||
}
|
||||
if loc := reBareURL.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string { return linkStyle.Render(s[loc[0]:loc[1]]) })
|
||||
}
|
||||
if loc := reRelPDFPath.FindStringIndex(s); loc != nil {
|
||||
consider(loc, func() string {
|
||||
relPath := s[loc[0]:loc[1]]
|
||||
styled := linkStyle.Render(relPath)
|
||||
if careerOpsPath == "" {
|
||||
return styled
|
||||
}
|
||||
joined := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
||||
absPath, err := filepath.Abs(joined)
|
||||
if err != nil {
|
||||
return styled
|
||||
}
|
||||
forward := filepath.ToSlash(absPath)
|
||||
if !strings.HasPrefix(forward, "/") {
|
||||
forward = "/" + forward // Windows: C:/... → /C:/...
|
||||
}
|
||||
// OSC 8 hyperlink: ESC ] 8 ; ; URL BEL text ESC ] 8 ; ; BEL
|
||||
return "\x1b]8;;" + "file://" + forward + "\x07" + styled + "\x1b]8;;\x07"
|
||||
})
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (m ViewerModel) styleLine(line string) string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
w := m.width - 6
|
||||
if w < 10 {
|
||||
w = 10
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "## ") {
|
||||
content := strings.TrimPrefix(trimmed, "# ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
|
||||
content := strings.TrimPrefix(trimmed, "## ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") && !strings.HasPrefix(trimmed, "#### ") {
|
||||
content := strings.TrimPrefix(trimmed, "### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "#### ") && !strings.HasPrefix(trimmed, "##### ") {
|
||||
content := strings.TrimPrefix(trimmed, "#### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Subtext).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "##### ") && !strings.HasPrefix(trimmed, "###### ") {
|
||||
content := strings.TrimPrefix(trimmed, "##### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "###### ") {
|
||||
content := strings.TrimPrefix(trimmed, "###### ")
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
||||
}
|
||||
if trimmed == "---" || trimmed == "***" {
|
||||
return lipgloss.NewStyle().Foreground(m.theme.Overlay).Width(w).Render(strings.Repeat("─", w))
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
content := strings.TrimPrefix(trimmed, "> ")
|
||||
border := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("▎ ")
|
||||
textStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Italic(true)
|
||||
wrapped := strings.Split(ansi.Wrap(textStyle.Render(content), w-2, ""), "\n")
|
||||
result := make([]string, 0, len(wrapped))
|
||||
for i, line := range wrapped {
|
||||
if i == 0 {
|
||||
result = append(result, border+line)
|
||||
} else {
|
||||
result = append(result, strings.Repeat(" ", ansi.StringWidth(border))+line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**") {
|
||||
styled := m.renderInlineElements(line)
|
||||
return ansi.Wrap(styled, w, "")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
content := trimmed[2:]
|
||||
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render("• ")
|
||||
return m.renderListItem(marker, content, w)
|
||||
}
|
||||
if reListNumber.MatchString(trimmed) {
|
||||
sm := reListNumber.FindStringSubmatch(trimmed)
|
||||
if len(sm) >= 3 {
|
||||
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render(sm[1])
|
||||
return m.renderListItem(marker, sm[2], w)
|
||||
}
|
||||
}
|
||||
|
||||
styled := m.renderInlineElementsAs(trimmed, m.theme.Subtext)
|
||||
return ansi.Wrap(styled, w, "")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderListItem(marker, content string, width int) string {
|
||||
markerWidth := ansi.StringWidth(marker)
|
||||
textWidth := width - markerWidth
|
||||
if textWidth < 10 {
|
||||
textWidth = 10
|
||||
}
|
||||
styled := m.renderInlineElementsAs(content, m.theme.Text)
|
||||
lines := strings.Split(ansi.Wrap(styled, textWidth, ""), "\n")
|
||||
result := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
if i == 0 {
|
||||
result = append(result, marker+line)
|
||||
} else {
|
||||
result = append(result, strings.Repeat(" ", markerWidth)+line)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func (m ViewerModel) renderFooter() string {
|
||||
style := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Subtext).
|
||||
Background(m.theme.Surface).
|
||||
Width(m.width).
|
||||
Padding(0, 1)
|
||||
|
||||
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
||||
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
||||
|
||||
if m.statusPicker {
|
||||
return style.Render(
|
||||
keyStyle.Render("↑/↓/j/k") + descStyle.Render(" select ") +
|
||||
keyStyle.Render("Enter") + descStyle.Render(" confirm ") +
|
||||
keyStyle.Render("Esc/q") + descStyle.Render(" cancel"))
|
||||
}
|
||||
|
||||
footer := keyStyle.Render("↑↓") + descStyle.Render(" scroll ") +
|
||||
keyStyle.Render("PgUp/Dn") + descStyle.Render(" page ") +
|
||||
keyStyle.Render("g/G") + descStyle.Render(" top/end ") +
|
||||
keyStyle.Render("c") + descStyle.Render(" status ") +
|
||||
keyStyle.Render("Esc") + descStyle.Render(" back")
|
||||
|
||||
if m.coverLetterPath != "" {
|
||||
footer += " " + keyStyle.Render("L") + descStyle.Render(" cover letter")
|
||||
}
|
||||
|
||||
return style.Render(footer)
|
||||
}
|
||||
|
||||
func (m ViewerModel) handleStatusPicker(msg tea.KeyMsg) (ViewerModel, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.statusPicker = false
|
||||
m.clampScrollOffset()
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
m.statusCursor++
|
||||
if m.statusCursor >= len(statusOptions) {
|
||||
m.statusCursor = len(statusOptions) - 1
|
||||
}
|
||||
|
||||
case "up", "k":
|
||||
m.statusCursor--
|
||||
if m.statusCursor < 0 {
|
||||
m.statusCursor = 0
|
||||
}
|
||||
|
||||
case "enter":
|
||||
m.statusPicker = false
|
||||
m.clampScrollOffset()
|
||||
newStatus := statusOptions[m.statusCursor]
|
||||
return m, func() tea.Msg {
|
||||
return ViewerUpdateStatusMsg{
|
||||
App: m.app,
|
||||
NewStatus: newStatus,
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ViewerModel) overlayStatusPicker(body string) string {
|
||||
bodyLines := strings.Split(body, "\n")
|
||||
|
||||
pickerWidth := 30
|
||||
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
||||
borderStyle := lipgloss.NewStyle().
|
||||
Foreground(m.theme.Blue).
|
||||
Bold(true)
|
||||
|
||||
var picker []string
|
||||
picker = append(picker, padStyle.Render(borderStyle.Render("Change status:")))
|
||||
|
||||
for i, opt := range statusOptions {
|
||||
style := lipgloss.NewStyle().Foreground(m.theme.Text).Width(pickerWidth)
|
||||
if i == m.statusCursor {
|
||||
style = style.Background(m.theme.Overlay).Bold(true)
|
||||
}
|
||||
prefix := " "
|
||||
if i == m.statusCursor {
|
||||
prefix = "> "
|
||||
}
|
||||
picker = append(picker, padStyle.Render(style.Render(prefix+opt)))
|
||||
}
|
||||
|
||||
bodyLines = append(bodyLines, picker...)
|
||||
return strings.Join(bodyLines, "\n")
|
||||
}
|
||||
|
||||
// UpdateAppStatus updates the status of the current application inside the viewer model.
|
||||
func (m *ViewerModel) UpdateAppStatus(newStatus string) {
|
||||
m.app.Status = newStatus
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"github.com/santifer/career-ops/dashboard/internal/theme"
|
||||
)
|
||||
|
||||
func TestViewerRebuildRenderClampsScrollOffset(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{"short"},
|
||||
scrollOffset: 20,
|
||||
width: 80,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
m.rebuildRender()
|
||||
|
||||
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
||||
if maxScroll < 0 {
|
||||
maxScroll = 0
|
||||
}
|
||||
if m.scrollOffset > maxScroll {
|
||||
t.Fatalf("expected scrollOffset <= %d after rebuild, got %d", maxScroll, m.scrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineElementsLeavesTrailingPunctuationUnstyled(t *testing.T) {
|
||||
match := reBareURL.FindString("Visit https://example.com.")
|
||||
|
||||
if match != "https://example.com" {
|
||||
t.Fatalf("expected URL match without trailing period, got %q", match)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerWrapsFencedCodeLines(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{
|
||||
"```",
|
||||
strings.Repeat("x", 40),
|
||||
"```",
|
||||
},
|
||||
width: 20,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.renderAll()
|
||||
maxWidth := m.width - 6
|
||||
if maxWidth < 10 {
|
||||
maxWidth = 10
|
||||
}
|
||||
|
||||
if len(rendered) < 2 {
|
||||
t.Fatalf("expected fenced code to wrap into multiple lines, got %d", len(rendered))
|
||||
}
|
||||
for _, line := range rendered {
|
||||
if width := ansi.StringWidth(line); width > maxWidth {
|
||||
t.Fatalf("expected fenced code line width <= %d, got %d for %q", maxWidth, width, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerRendersInlineMarkdownBeforeParagraphWrapping(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: []string{
|
||||
"See [documentation](https://example.com/really-long-path) before continuing.",
|
||||
},
|
||||
width: 30,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := strings.Join(m.renderAll(), "\n")
|
||||
plain := ansi.Strip(rendered)
|
||||
|
||||
if strings.Contains(plain, "[") || strings.Contains(plain, "](") {
|
||||
t.Fatalf("expected rendered paragraph to hide markdown link syntax, got %q", plain)
|
||||
}
|
||||
if !strings.Contains(plain, "documentation") {
|
||||
t.Fatalf("expected rendered paragraph to keep link text, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerEmptyContentRendersPlaceholder(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
lines: nil,
|
||||
width: 40,
|
||||
height: 10,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
m.rebuildRender()
|
||||
|
||||
if len(m.renderedLines) != 0 {
|
||||
t.Fatalf("expected zero rendered lines for empty content, got %d", len(m.renderedLines))
|
||||
}
|
||||
|
||||
body := ansi.Strip(m.renderBody())
|
||||
if !strings.Contains(body, "(empty file)") {
|
||||
t.Fatalf("expected empty placeholder, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerInlineRenderingHandlesMixedTokens(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
width: 60,
|
||||
height: 10,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.renderInlineElementsAs(
|
||||
"start `code` mid **bold** then [link](https://example.com) end https://bare.example.com.",
|
||||
m.theme.Subtext,
|
||||
)
|
||||
plain := ansi.Strip(rendered)
|
||||
|
||||
for _, want := range []string{"start ", "code", " mid ", "bold", " then ", "link", " end ", "https://bare.example.com"} {
|
||||
if !strings.Contains(plain, want) {
|
||||
t.Fatalf("expected plain output to contain %q, got %q", want, plain)
|
||||
}
|
||||
}
|
||||
for _, syntax := range []string{"`", "**", "[", "](", "](http"} {
|
||||
if strings.Contains(plain, syntax) {
|
||||
t.Fatalf("expected markdown syntax %q to be hidden, got %q", syntax, plain)
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(plain, ".") == false {
|
||||
t.Fatalf("expected trailing punctuation outside the bare URL, got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerIndentsWrappedBlockquoteLines(t *testing.T) {
|
||||
m := ViewerModel{
|
||||
width: 24,
|
||||
height: 20,
|
||||
theme: theme.NewTheme("catppuccin-mocha"),
|
||||
}
|
||||
|
||||
rendered := m.styleLine("> " + strings.Repeat("quoted ", 8))
|
||||
lines := strings.Split(ansi.Strip(rendered), "\n")
|
||||
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("expected wrapped blockquote to render multiple lines, got %d", len(lines))
|
||||
}
|
||||
if !strings.HasPrefix(lines[0], "▎ ") {
|
||||
t.Fatalf("expected first blockquote line to keep border, got %q", lines[0])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], " ") {
|
||||
t.Fatalf("expected wrapped blockquote continuation to align with text, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user