main_test.go

  1package main
  2
  3import (
  4	"path/filepath"
  5	"strings"
  6	"testing"
  7	"unicode/utf8"
  8
  9	"github.com/floatpane/matcha/config"
 10	"github.com/floatpane/matcha/fetcher"
 11	"github.com/floatpane/matcha/tui"
 12)
 13
 14func TestSanitizeFilenameTruncatesCJKOnUTF8Boundary(t *testing.T) {
 15	name := strings.Repeat("文", 100) + ".txt"
 16
 17	got := sanitizeFilename(name)
 18
 19	if !utf8.ValidString(got) {
 20		t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
 21	}
 22	if len(got) > 255 {
 23		t.Fatalf("sanitizeFilename returned %d bytes, want at most 255", len(got))
 24	}
 25	if filepath.Ext(got) != ".txt" {
 26		t.Fatalf("sanitizeFilename lost extension: got %q", got)
 27	}
 28}
 29
 30func TestSanitizeFilenameTruncatesEmojiOnUTF8Boundary(t *testing.T) {
 31	name := strings.Repeat("🚀", 80) + ".log"
 32
 33	got := sanitizeFilename(name)
 34
 35	if !utf8.ValidString(got) {
 36		t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
 37	}
 38	if len(got) > 255 {
 39		t.Fatalf("sanitizeFilename returned %d bytes, want at most 255", len(got))
 40	}
 41	if filepath.Ext(got) != ".log" {
 42		t.Fatalf("sanitizeFilename lost extension: got %q", got)
 43	}
 44}
 45
 46func TestParseGlobalFlagsEnablesLogPanel(t *testing.T) {
 47	args, _, show := parseGlobalFlags([]string{"matcha", "--debug", "--logs", "--version"})
 48	if !show {
 49		t.Fatal("expected log panel flag to be enabled")
 50	}
 51	if got := strings.Join(args, " "); got != "matcha --version" {
 52		t.Fatalf("args = %q, want %q", got, "matcha --version")
 53	}
 54}
 55
 56func TestParseGlobalFlagsDoesNotConsumeSubcommandFlags(t *testing.T) {
 57	args, _, show := parseGlobalFlags([]string{"matcha", "send", "--logs"})
 58	if show {
 59		t.Fatal("did not expect log panel flag after subcommand to be consumed")
 60	}
 61	if got := strings.Join(args, " "); got != "matcha send --logs" {
 62		t.Fatalf("args = %q, want %q", got, "matcha send --logs")
 63	}
 64}
 65
 66// newFolderCounterModel builds a minimal mainModel whose INBOX folder holds the
 67// given emails and reports the given unread count. It is used to verify that the
 68// per-folder unread counter shown in the sidebar is updated immediately after
 69// delete/archive operations, without waiting for the next fetch.
 70func newFolderCounterModel(emails []fetcher.Email, unread int) *mainModel {
 71	accountID := "acct-a"
 72	cfg := &config.Config{
 73		Accounts: []config.Account{{ID: accountID, Email: "a@example.com"}},
 74	}
 75
 76	fi := tui.NewFolderInbox([]string{folderInbox}, cfg.Accounts)
 77	fi.SetUnreadCounts(map[string]int{folderInbox: unread})
 78	fi.SetEmails(emails, cfg.Accounts)
 79
 80	byAcct := make(map[string][]fetcher.Email)
 81	for _, e := range emails {
 82		byAcct[e.AccountID] = append(byAcct[e.AccountID], e)
 83	}
 84
 85	m := &mainModel{
 86		config:       cfg,
 87		folderInbox:  fi,
 88		current:      fi,
 89		emails:       emails,
 90		emailsByAcct: byAcct,
 91		folderEmails: map[string][]fetcher.Email{folderInbox: emails},
 92	}
 93	return m
 94}
 95
 96// TestDeleteUnreadEmailUpdatesFolderCounter verifies that deleting an unread
 97// email immediately decrements the folder's unread counter in the sidebar,
 98// rather than waiting for the next fetch (issue #1404).
 99func TestDeleteUnreadEmailUpdatesFolderCounter(t *testing.T) {
100	email := fetcher.Email{UID: 42, AccountID: "acct-a", IsRead: false}
101	m := newFolderCounterModel([]fetcher.Email{email}, 1)
102
103	m.Update(tui.DeleteEmailMsg{UID: 42, AccountID: "acct-a", Mailbox: folderInbox})
104
105	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
106		t.Fatalf("after deleting an unread email, folder unread count = %d, want 0", got)
107	}
108}
109
110// TestArchiveUnreadEmailUpdatesFolderCounter verifies the same immediate update
111// for archive operations.
112func TestArchiveUnreadEmailUpdatesFolderCounter(t *testing.T) {
113	email := fetcher.Email{UID: 7, AccountID: "acct-a", IsRead: false}
114	m := newFolderCounterModel([]fetcher.Email{email}, 1)
115
116	m.Update(tui.ArchiveEmailMsg{UID: 7, AccountID: "acct-a", Mailbox: folderInbox})
117
118	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
119		t.Fatalf("after archiving an unread email, folder unread count = %d, want 0", got)
120	}
121}
122
123// TestBatchDeleteUnreadEmailsUpdatesFolderCounter verifies that batch delete
124// decrements the folder counter once per unread email removed, while leaving
125// already-read emails untouched.
126func TestBatchDeleteUnreadEmailsUpdatesFolderCounter(t *testing.T) {
127	emails := []fetcher.Email{
128		{UID: 1, AccountID: "acct-a", IsRead: false},
129		{UID: 2, AccountID: "acct-a", IsRead: false},
130		{UID: 3, AccountID: "acct-a", IsRead: true},
131	}
132	m := newFolderCounterModel(emails, 2)
133
134	m.Update(tui.BatchDeleteEmailsMsg{UIDs: []uint32{1, 2, 3}, AccountID: "acct-a", Mailbox: folderInbox})
135
136	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
137		t.Fatalf("after batch-deleting two unread emails, folder unread count = %d, want 0", got)
138	}
139}
140
141// TestDeleteReadEmailLeavesFolderCounter verifies that deleting an already-read
142// email does not change the folder unread counter.
143func TestDeleteReadEmailLeavesFolderCounter(t *testing.T) {
144	emails := []fetcher.Email{
145		{UID: 10, AccountID: "acct-a", IsRead: true},
146		{UID: 11, AccountID: "acct-a", IsRead: false},
147	}
148	m := newFolderCounterModel(emails, 1)
149
150	m.Update(tui.DeleteEmailMsg{UID: 10, AccountID: "acct-a", Mailbox: folderInbox})
151
152	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 {
153		t.Fatalf("after deleting a read email, folder unread count = %d, want 1", got)
154	}
155}
156
157// TestUndoDeleteRestoresFolderCounter verifies that undoing a delete restores
158// the folder unread counter that was decremented when the email was deleted.
159func TestUndoDeleteRestoresFolderCounter(t *testing.T) {
160	email := fetcher.Email{UID: 99, AccountID: "acct-a", IsRead: false}
161	m := newFolderCounterModel([]fetcher.Email{email}, 1)
162
163	m.Update(tui.DeleteEmailMsg{UID: 99, AccountID: "acct-a", Mailbox: folderInbox})
164	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 0 {
165		t.Fatalf("after delete, folder unread count = %d, want 0", got)
166	}
167
168	m.restorePendingAction()
169	if got := m.folderInbox.GetUnreadCountsCopy()[folderInbox]; got != 1 {
170		t.Fatalf("after undo, folder unread count = %d, want 1", got)
171	}
172}
173
174func TestUnreadBadgeCountDeduplicatesOverlappingStores(t *testing.T) {
175	email := fetcher.Email{UID: 42, AccountID: "acct-a"}
176	got := unreadBadgeCount(
177		map[string][]fetcher.Email{
178			"acct-a": {email},
179		},
180		map[string][]fetcher.Email{
181			folderInbox: {email},
182		},
183	)
184
185	if got != 1 {
186		t.Fatalf("unreadBadgeCount() = %d, want 1", got)
187	}
188}