cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package ui
2
3import (
4 "bytes"
5 "context"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/charmbracelet/bubbles/help"
11 "github.com/charmbracelet/bubbles/viewport"
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14)
15
16func createMockNote() *models.Note {
17 now := time.Now()
18 publishedAt := now.Add(-24 * time.Hour)
19 rkey := "test-rkey-123"
20 cid := "test-cid-456"
21
22 return &models.Note{
23 ID: 1,
24 Title: "Test Publication",
25 Content: "# Test Publication\n\nThis is the content of the test publication.",
26 Tags: []string{"test", "publication"},
27 Archived: false,
28 Created: now.Add(-48 * time.Hour),
29 Modified: now.Add(-1 * time.Hour),
30 LeafletRKey: &rkey,
31 LeafletCID: &cid,
32 PublishedAt: &publishedAt,
33 IsDraft: false,
34 }
35}
36
37func createDraftNote() *models.Note {
38 note := createMockNote()
39 note.IsDraft = true
40 note.PublishedAt = nil
41 note.LeafletRKey = nil
42 note.LeafletCID = nil
43 return note
44}
45
46func createMinimalNote() *models.Note {
47 now := time.Now()
48 return &models.Note{
49 ID: 2,
50 Title: "Minimal Note",
51 Content: "Simple content without markdown heading.",
52 Created: now,
53 Modified: now,
54 IsDraft: true,
55 }
56}
57
58func TestPublicationView(t *testing.T) {
59 t.Run("View Options", func(t *testing.T) {
60 note := createMockNote()
61
62 t.Run("default options", func(t *testing.T) {
63 opts := PublicationViewOptions{}
64 pv := NewPublicationView(note, opts)
65
66 if pv.opts.Output == nil {
67 t.Error("Output should default to os.Stdout")
68 }
69 if pv.opts.Input == nil {
70 t.Error("Input should default to os.Stdin")
71 }
72 if pv.opts.Width != 80 {
73 t.Errorf("Width should default to 80, got %d", pv.opts.Width)
74 }
75 if pv.opts.Height != 24 {
76 t.Errorf("Height should default to 24, got %d", pv.opts.Height)
77 }
78 })
79
80 t.Run("custom options", func(t *testing.T) {
81 var buf bytes.Buffer
82 opts := PublicationViewOptions{
83 Output: &buf,
84 Static: true,
85 Width: 100,
86 Height: 30,
87 }
88 pv := NewPublicationView(note, opts)
89
90 if pv.opts.Output != &buf {
91 t.Error("Custom output not set")
92 }
93 if !pv.opts.Static {
94 t.Error("Static mode not set")
95 }
96 if pv.opts.Width != 100 {
97 t.Error("Custom width not set")
98 }
99 if pv.opts.Height != 30 {
100 t.Error("Custom height not set")
101 }
102 })
103 })
104
105 t.Run("New", func(t *testing.T) {
106 note := createMockNote()
107
108 t.Run("creates publication view correctly", func(t *testing.T) {
109 opts := PublicationViewOptions{Width: 60, Height: 20}
110 pv := NewPublicationView(note, opts)
111
112 if pv.note != note {
113 t.Error("Note not set correctly")
114 }
115 if pv.opts.Width != 60 {
116 t.Error("Width not set correctly")
117 }
118 if pv.opts.Height != 20 {
119 t.Error("Height not set correctly")
120 }
121 })
122 })
123
124 t.Run("Static Mode", func(t *testing.T) {
125 t.Run("published note display", func(t *testing.T) {
126 note := createMockNote()
127 var buf bytes.Buffer
128
129 pv := NewPublicationView(note, PublicationViewOptions{
130 Output: &buf,
131 Static: true,
132 })
133
134 err := pv.Show(context.Background())
135 if err != nil {
136 t.Fatalf("Show failed: %v", err)
137 }
138
139 output := buf.String()
140
141 if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") {
142 t.Error("Note title not displayed")
143 }
144 if !strings.Contains(output, "published") {
145 t.Error("Published status not displayed")
146 }
147 if !strings.Contains(output, "Published:") {
148 t.Error("Published date not displayed")
149 }
150 if !strings.Contains(output, "Modified:") {
151 t.Error("Modified date not displayed")
152 }
153 if !strings.Contains(output, "RKey:") {
154 t.Error("RKey not displayed")
155 }
156 if !strings.Contains(output, "tes") || !strings.Contains(output, "123") {
157 t.Error("RKey value not displayed")
158 }
159 if !strings.Contains(output, "CID:") {
160 t.Error("CID not displayed")
161 }
162 if !strings.Contains(output, "tes") || !strings.Contains(output, "456") {
163 t.Error("CID value not displayed")
164 }
165 if !strings.Contains(output, "This") || !strings.Contains(output, "content") {
166 t.Error("Note content not displayed")
167 }
168 })
169
170 t.Run("draft note display", func(t *testing.T) {
171 note := createDraftNote()
172 var buf bytes.Buffer
173
174 pv := NewPublicationView(note, PublicationViewOptions{
175 Output: &buf,
176 Static: true,
177 })
178
179 err := pv.Show(context.Background())
180 if err != nil {
181 t.Fatalf("Show failed: %v", err)
182 }
183
184 output := buf.String()
185
186 if !strings.Contains(output, "draft") {
187 t.Error("Draft status not displayed")
188 }
189 if strings.Contains(output, "Published:") {
190 t.Error("Published date should not be displayed for draft")
191 }
192 if strings.Contains(output, "RKey:") {
193 t.Error("RKey should not be displayed for draft")
194 }
195 if strings.Contains(output, "CID:") {
196 t.Error("CID should not be displayed for draft")
197 }
198 })
199
200 t.Run("minimal note display", func(t *testing.T) {
201 note := createMinimalNote()
202 var buf bytes.Buffer
203
204 pv := NewPublicationView(note, PublicationViewOptions{
205 Output: &buf,
206 Static: true,
207 })
208
209 err := pv.Show(context.Background())
210 if err != nil {
211 t.Fatalf("Show failed: %v", err)
212 }
213
214 output := buf.String()
215
216 if !strings.Contains(output, "Minimal") || !strings.Contains(output, "Note") {
217 t.Error("Note title not displayed")
218 }
219 if !strings.Contains(output, "Simple") || !strings.Contains(output, "content") {
220 t.Error("Note content not displayed")
221 }
222 if !strings.Contains(output, "Modified:") {
223 t.Error("Modified date not displayed")
224 }
225 })
226 })
227
228 t.Run("Build Markdown", func(t *testing.T) {
229 t.Run("builds markdown for published note", func(t *testing.T) {
230 note := createMockNote()
231 markdown := buildPublicationMarkdown(note)
232
233 expectedStrings := []string{
234 "# Test Publication",
235 "**Status:** published",
236 "**Published:**",
237 "**Modified:**",
238 "**RKey:**",
239 "**CID:**",
240 "---",
241 "This is the content",
242 }
243
244 for _, expected := range expectedStrings {
245 if !strings.Contains(markdown, expected) {
246 t.Errorf("Expected markdown '%s' not found in output", expected)
247 }
248 }
249 })
250
251 t.Run("builds markdown for draft note", func(t *testing.T) {
252 note := createDraftNote()
253 markdown := buildPublicationMarkdown(note)
254
255 if !strings.Contains(markdown, "**Status:** draft") {
256 t.Error("Draft status not in markdown")
257 }
258 if strings.Contains(markdown, "**Published:**") {
259 t.Error("Published date should not be in draft markdown")
260 }
261 if strings.Contains(markdown, "**RKey:**") {
262 t.Error("RKey should not be in draft markdown")
263 }
264 if strings.Contains(markdown, "**CID:**") {
265 t.Error("CID should not be in draft markdown")
266 }
267 })
268
269 t.Run("handles content with markdown heading", func(t *testing.T) {
270 note := createMockNote()
271 markdown := buildPublicationMarkdown(note)
272
273 headingCount := strings.Count(markdown, "# Test Publication")
274 if headingCount != 1 {
275 t.Errorf("Expected 1 heading, found %d (content heading should be stripped)", headingCount)
276 }
277 })
278
279 t.Run("handles content without markdown heading", func(t *testing.T) {
280 note := createMinimalNote()
281 markdown := buildPublicationMarkdown(note)
282
283 if !strings.Contains(markdown, "Simple content") {
284 t.Error("Content without heading should be included as-is")
285 }
286 })
287 })
288
289 t.Run("Format Content", func(t *testing.T) {
290 t.Run("formats content with glamour", func(t *testing.T) {
291 note := createMockNote()
292 content, err := formatPublicationContent(note)
293
294 if err != nil {
295 t.Fatalf("formatPublicationContent failed: %v", err)
296 }
297
298 if len(content) == 0 {
299 t.Error("Formatted content should not be empty")
300 }
301 })
302
303 t.Run("includes note title in formatted content", func(t *testing.T) {
304 note := createMockNote()
305 content, err := formatPublicationContent(note)
306
307 if err != nil {
308 t.Fatalf("formatPublicationContent failed: %v", err)
309 }
310
311 if !strings.Contains(content, "Test") || !strings.Contains(content, "Publication") {
312 t.Error("Formatted content should include note title")
313 }
314 })
315 })
316
317 t.Run("Model", func(t *testing.T) {
318 note := createMockNote()
319
320 t.Run("initial model state", func(t *testing.T) {
321 model := publicationViewModel{
322 note: note,
323 opts: PublicationViewOptions{Width: 80, Height: 24},
324 }
325
326 if model.showingHelp {
327 t.Error("Initial showingHelp should be false")
328 }
329 if model.note != note {
330 t.Error("Note not set correctly")
331 }
332 })
333
334 t.Run("key handling - help toggle", func(t *testing.T) {
335 vp := viewport.New(80, 20)
336 model := publicationViewModel{
337 note: note,
338 viewport: vp,
339 keys: publicationViewKeys,
340 help: help.New(),
341 ready: true,
342 }
343
344 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
345 if m, ok := newModel.(publicationViewModel); ok {
346 if !m.showingHelp {
347 t.Error("Help key should show help")
348 }
349 }
350
351 model.showingHelp = true
352 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
353 if m, ok := newModel.(publicationViewModel); ok {
354 if m.showingHelp {
355 t.Error("Help key should exit help when already showing")
356 }
357 }
358 })
359
360 t.Run("key handling - quit and back", func(t *testing.T) {
361 vp := viewport.New(80, 20)
362 model := publicationViewModel{
363 note: note,
364 viewport: vp,
365 keys: publicationViewKeys,
366 help: help.New(),
367 ready: true,
368 }
369
370 quitKeys := []string{"q", "esc"}
371 for _, key := range quitKeys {
372 _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)})
373 if cmd == nil {
374 t.Errorf("Key %s should return quit command", key)
375 }
376 }
377 })
378
379 t.Run("viewport navigation", func(t *testing.T) {
380 vp := viewport.New(80, 20)
381 longContent := strings.Repeat("Line of content\n", 50)
382 vp.SetContent(longContent)
383
384 model := publicationViewModel{
385 note: note,
386 viewport: vp,
387 keys: publicationViewKeys,
388 help: help.New(),
389 ready: true,
390 }
391
392 initialOffset := model.viewport.YOffset
393
394 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
395 if m, ok := newModel.(publicationViewModel); ok {
396 if m.viewport.YOffset <= initialOffset {
397 t.Error("Down key should scroll viewport down")
398 }
399 }
400
401 model.viewport.ScrollDown(5)
402 initialOffset = model.viewport.YOffset
403 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
404 if m, ok := newModel.(publicationViewModel); ok {
405 if m.viewport.YOffset >= initialOffset {
406 t.Error("Up key should scroll viewport up")
407 }
408 }
409 })
410
411 t.Run("page navigation", func(t *testing.T) {
412 vp := viewport.New(80, 20)
413 longContent := strings.Repeat("Line of content\n", 100)
414 vp.SetContent(longContent)
415
416 model := publicationViewModel{
417 note: note,
418 viewport: vp,
419 keys: publicationViewKeys,
420 help: help.New(),
421 ready: true,
422 }
423
424 initialOffset := model.viewport.YOffset
425
426 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")})
427 if m, ok := newModel.(publicationViewModel); ok {
428 if m.viewport.YOffset <= initialOffset {
429 t.Error("Page down key should scroll viewport down")
430 }
431 }
432 })
433
434 t.Run("top and bottom navigation", func(t *testing.T) {
435 vp := viewport.New(80, 20)
436 longContent := strings.Repeat("Line of content\n", 100)
437 vp.SetContent(longContent)
438
439 model := publicationViewModel{
440 note: note,
441 viewport: vp,
442 keys: publicationViewKeys,
443 help: help.New(),
444 ready: true,
445 }
446
447 model.viewport.ScrollDown(50)
448
449 newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")})
450 if m, ok := newModel.(publicationViewModel); ok {
451 if m.viewport.YOffset != 0 {
452 t.Error("Top key should scroll to top")
453 }
454 }
455
456 newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")})
457 if m, ok := newModel.(publicationViewModel); ok {
458 if m.viewport.YOffset == 0 {
459 t.Error("Bottom key should scroll to bottom")
460 }
461 }
462 })
463
464 t.Run("window size message handling", func(t *testing.T) {
465 vp := viewport.New(80, 20)
466 model := publicationViewModel{
467 note: note,
468 viewport: vp,
469 keys: publicationViewKeys,
470 help: help.New(),
471 opts: PublicationViewOptions{Static: false},
472 }
473
474 newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
475 if m, ok := newModel.(publicationViewModel); ok {
476 if m.viewport.Width != 98 {
477 t.Errorf("Viewport width should be 98 (100-2), got %d", m.viewport.Width)
478 }
479 expectedHeight := 30 - 6
480 if m.viewport.Height != expectedHeight {
481 t.Errorf("Viewport height should be %d, got %d", expectedHeight, m.viewport.Height)
482 }
483 if !m.ready {
484 t.Error("Model should be ready after window size message")
485 }
486 }
487 })
488
489 t.Run("static mode ignores window resize", func(t *testing.T) {
490 vp := viewport.New(80, 20)
491 model := publicationViewModel{
492 note: note,
493 viewport: vp,
494 keys: publicationViewKeys,
495 help: help.New(),
496 opts: PublicationViewOptions{Static: true},
497 ready: true,
498 }
499
500 newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
501 if m, ok := newModel.(publicationViewModel); ok {
502 if m.viewport.Width != 80 {
503 t.Error("Static mode should not resize viewport width")
504 }
505 if m.viewport.Height != 20 {
506 t.Error("Static mode should not resize viewport height")
507 }
508 }
509 })
510 })
511
512 t.Run("View Model", func(t *testing.T) {
513 note := createMockNote()
514
515 t.Run("normal view with published note", func(t *testing.T) {
516 vp := viewport.New(80, 20)
517 content, _ := formatPublicationContent(note)
518 vp.SetContent(content)
519
520 model := publicationViewModel{
521 note: note,
522 viewport: vp,
523 keys: publicationViewKeys,
524 help: help.New(),
525 ready: true,
526 }
527
528 view := model.View()
529
530 if !strings.Contains(view, "Test Publication") {
531 t.Error("Note title not displayed in view")
532 }
533 if !strings.Contains(view, "published") {
534 t.Error("Published status not displayed in view")
535 }
536 })
537
538 t.Run("normal view with draft note", func(t *testing.T) {
539 draft := createDraftNote()
540 vp := viewport.New(80, 20)
541 content, _ := formatPublicationContent(draft)
542 vp.SetContent(content)
543
544 model := publicationViewModel{
545 note: draft,
546 viewport: vp,
547 keys: publicationViewKeys,
548 help: help.New(),
549 ready: true,
550 }
551
552 view := model.View()
553
554 if !strings.Contains(view, "draft") {
555 t.Error("Draft status not displayed in view")
556 }
557 })
558
559 t.Run("help view", func(t *testing.T) {
560 vp := viewport.New(80, 20)
561 model := publicationViewModel{
562 note: note,
563 viewport: vp,
564 keys: publicationViewKeys,
565 help: help.New(),
566 showingHelp: true,
567 ready: true,
568 }
569
570 view := model.View()
571
572 if !strings.Contains(view, "scroll") {
573 t.Error("Help view should contain scroll instructions")
574 }
575 })
576
577 t.Run("initializing view", func(t *testing.T) {
578 vp := viewport.New(80, 20)
579 model := publicationViewModel{
580 note: note,
581 viewport: vp,
582 keys: publicationViewKeys,
583 help: help.New(),
584 ready: false,
585 }
586
587 view := model.View()
588
589 if !strings.Contains(view, "Initializing") {
590 t.Error("Not ready state should show initializing message")
591 }
592 })
593 })
594
595 t.Run("Key Bindings", func(t *testing.T) {
596 t.Run("short help bindings", func(t *testing.T) {
597 bindings := publicationViewKeys.ShortHelp()
598 if len(bindings) != 5 {
599 t.Errorf("Expected 5 short help bindings, got %d", len(bindings))
600 }
601 })
602
603 t.Run("full help bindings", func(t *testing.T) {
604 bindings := publicationViewKeys.FullHelp()
605 if len(bindings) != 3 {
606 t.Errorf("Expected 3 rows of full help bindings, got %d", len(bindings))
607 }
608 })
609 })
610
611 t.Run("Integration", func(t *testing.T) {
612 t.Run("creates and displays publication view", func(t *testing.T) {
613 note := createMockNote()
614 var buf bytes.Buffer
615
616 pv := NewPublicationView(note, PublicationViewOptions{
617 Output: &buf,
618 Static: true,
619 Width: 80,
620 Height: 24,
621 })
622
623 if pv == nil {
624 t.Fatal("NewPublicationView returned nil")
625 }
626
627 err := pv.Show(context.Background())
628 if err != nil {
629 t.Fatalf("Show failed: %v", err)
630 }
631
632 output := buf.String()
633 if len(output) == 0 {
634 t.Error("No output generated")
635 }
636
637 if !strings.Contains(output, "Test") || !strings.Contains(output, "Publication") {
638 t.Error("Note title not displayed")
639 }
640 if !strings.Contains(output, "This") || !strings.Contains(output, "content") {
641 t.Error("Note content not displayed")
642 }
643 })
644
645 t.Run("creates publication view for draft", func(t *testing.T) {
646 draft := createDraftNote()
647 var buf bytes.Buffer
648
649 pv := NewPublicationView(draft, PublicationViewOptions{
650 Output: &buf,
651 Static: true,
652 })
653
654 err := pv.Show(context.Background())
655 if err != nil {
656 t.Fatalf("Show failed: %v", err)
657 }
658
659 output := buf.String()
660 if !strings.Contains(output, "draft") {
661 t.Error("Draft status not displayed")
662 }
663 })
664 })
665}