cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package models
2
3import (
4 "encoding/json"
5 "fmt"
6 "testing"
7 "time"
8)
9
10func TestModels(t *testing.T) {
11 t.Run("Model Interface", func(t *testing.T) {
12 now := time.Now()
13 time.Sleep(time.Duration(500) * time.Duration(time.Millisecond))
14 updated := time.Now()
15
16 for i, tc := range []struct {
17 name string
18 model Model
19 unmarshaled Model
20 }{
21 {name: "Task", model: &Task{ID: 1, Entry: now, Modified: updated}, unmarshaled: &Task{}},
22 {name: "Movie", model: &Movie{ID: 1, Title: "Test Movie", Year: 2023, Added: now}, unmarshaled: &Movie{}},
23 {name: "TVShow", model: &TVShow{ID: 1, Title: "Test Show", Added: now}, unmarshaled: &TVShow{}},
24 {name: "Book", model: &Book{ID: 1, Title: "Test Book", Added: now}, unmarshaled: &Book{}},
25 {name: "Note", model: &Note{ID: 1, Title: "Test Note", Content: "This is test content", Created: now}, unmarshaled: &Note{}},
26 {name: "Album", model: &Album{ID: 1, Title: "Test Album", Artist: "Test Artist", Created: now}, unmarshaled: &Album{}},
27 {name: "TimeEntry", model: &TimeEntry{ID: 1, TaskID: 100, Created: now, Modified: updated}, unmarshaled: &TimeEntry{}},
28 {name: "Article", model: &Article{ID: 1, Created: now, Modified: updated}, unmarshaled: &Article{}},
29 } {
30 model := tc.model
31 t.Run(fmt.Sprintf("%v Implementation", tc.name), func(t *testing.T) {
32 model.SetID(int64(i + 1))
33 if model.GetID() != int64(i+1) {
34 t.Errorf("Model %d: ID not set correctly", i)
35 }
36
37 tableName := model.GetTableName()
38 if tableName == "" {
39 t.Errorf("Model %d: table name should not be empty", i)
40 }
41
42 now = time.Now()
43 model.SetCreatedAt(now)
44 // NOTE: We don't test exact equality due to potential precision differences
45 if model.GetCreatedAt().IsZero() {
46 t.Errorf("Model %d: created at should not be zero", i)
47 }
48
49 updatedAt := time.Now().Add(time.Hour)
50 model.SetUpdatedAt(updatedAt)
51 if !model.GetUpdatedAt().Equal(updatedAt) {
52 t.Errorf("Expected updated at %v, got %v", updatedAt, model.GetUpdatedAt())
53 }
54
55 if model.GetUpdatedAt().IsZero() {
56 t.Errorf("Model %d: updated at should not be zero", i)
57 }
58 model.SetUpdatedAt(now)
59
60 t.Run(fmt.Sprintf("%v JSON Marshal/Unmarshal", tc.name), func(t *testing.T) {
61 if data, err := json.Marshal(model); err != nil {
62 t.Fatalf("JSON marshal failed: %v", err)
63 } else {
64 var unmarshaled = tc.unmarshaled
65 if err = json.Unmarshal(data, &unmarshaled); err != nil {
66 t.Fatalf("JSON unmarshal failed: %v", err)
67 }
68
69 if unmarshaled.GetID() != model.GetID() {
70 t.Fatalf("IDs should be the same")
71 }
72 }
73 })
74 })
75 }
76
77 })
78
79 t.Run("Task Model", func(t *testing.T) {
80 t.Run("Status Methods", func(t *testing.T) {
81 tc := []struct {
82 status string
83 isCompleted bool
84 isPending bool
85 isDeleted bool
86 }{
87 {"pending", false, true, false},
88 {"completed", true, false, false},
89 {"deleted", false, false, true},
90 {"unknown", false, false, false},
91 }
92
93 for _, tt := range tc {
94 task := &Task{Status: tt.status}
95
96 if task.IsCompleted() != tt.isCompleted {
97 t.Errorf("Status %s: expected IsCompleted %v, got %v", tt.status, tt.isCompleted, task.IsCompleted())
98 }
99 if task.IsPending() != tt.isPending {
100 t.Errorf("Status %s: expected IsPending %v, got %v", tt.status, tt.isPending, task.IsPending())
101 }
102 if task.IsDeleted() != tt.isDeleted {
103 t.Errorf("Status %s: expected IsDeleted %v, got %v", tt.status, tt.isDeleted, task.IsDeleted())
104 }
105 }
106 })
107
108 t.Run("New Status Tracking Methods", func(t *testing.T) {
109 tc := []struct {
110 status string
111 isTodo bool
112 isInProgress bool
113 isBlocked bool
114 isDone bool
115 isAbandoned bool
116 }{
117 {StatusTodo, true, false, false, false, false},
118 {StatusInProgress, false, true, false, false, false},
119 {StatusBlocked, false, false, true, false, false},
120 {StatusDone, false, false, false, true, false},
121 {StatusAbandoned, false, false, false, false, true},
122 {"unknown", false, false, false, false, false},
123 }
124
125 for _, tt := range tc {
126 task := &Task{Status: tt.status}
127
128 if task.IsTodo() != tt.isTodo {
129 t.Errorf("Status %s: expected IsTodo %v, got %v", tt.status, tt.isTodo, task.IsTodo())
130 }
131 if task.IsInProgress() != tt.isInProgress {
132 t.Errorf("Status %s: expected IsInProgress %v, got %v", tt.status, tt.isInProgress, task.IsInProgress())
133 }
134 if task.IsBlocked() != tt.isBlocked {
135 t.Errorf("Status %s: expected IsBlocked %v, got %v", tt.status, tt.isBlocked, task.IsBlocked())
136 }
137 if task.IsDone() != tt.isDone {
138 t.Errorf("Status %s: expected IsDone %v, got %v", tt.status, tt.isDone, task.IsDone())
139 }
140 if task.IsAbandoned() != tt.isAbandoned {
141 t.Errorf("Status %s: expected IsAbandoned %v, got %v", tt.status, tt.isAbandoned, task.IsAbandoned())
142 }
143 }
144 })
145
146 t.Run("Status Validation", func(t *testing.T) {
147 validStatuses := []string{
148 StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned,
149 StatusPending, StatusCompleted, StatusDeleted,
150 }
151
152 for _, status := range validStatuses {
153 task := &Task{Status: status}
154 if !task.IsValidStatus() {
155 t.Errorf("Status %s should be valid", status)
156 }
157 }
158
159 invalidStatuses := []string{"unknown", "invalid", ""}
160 for _, status := range invalidStatuses {
161 task := &Task{Status: status}
162 if task.IsValidStatus() {
163 t.Errorf("Status %s should be invalid", status)
164 }
165 }
166 })
167
168 t.Run("Priority Methods", func(t *testing.T) {
169 task := &Task{}
170
171 if task.HasPriority() {
172 t.Error("Task with empty priority should return false for HasPriority")
173 }
174
175 task.Priority = "A"
176 if !task.HasPriority() {
177 t.Error("Task with priority should return true for HasPriority")
178 }
179 })
180
181 t.Run("Priority System", func(t *testing.T) {
182 t.Run("Text-based Priority Validation", func(t *testing.T) {
183 validTextPriorities := []string{
184 PriorityHigh, PriorityMedium, PriorityLow,
185 }
186
187 for _, priority := range validTextPriorities {
188 task := &Task{Priority: priority}
189 if !task.IsValidPriority() {
190 t.Errorf("Priority %s should be valid", priority)
191 }
192 }
193 })
194
195 t.Run("Numeric Priority Validation", func(t *testing.T) {
196 validNumericPriorities := []string{"1", "2", "3", "4", "5"}
197
198 for _, priority := range validNumericPriorities {
199 task := &Task{Priority: priority}
200 if !task.IsValidPriority() {
201 t.Errorf("Numeric priority %s should be valid", priority)
202 }
203 }
204
205 invalidNumericPriorities := []string{"0", "6", "10", "-1"}
206 for _, priority := range invalidNumericPriorities {
207 task := &Task{Priority: priority}
208 if task.IsValidPriority() {
209 t.Errorf("Numeric priority %s should be invalid", priority)
210 }
211 }
212 })
213
214 t.Run("Legacy A-Z Priority Validation", func(t *testing.T) {
215 validLegacyPriorities := []string{"A", "B", "C", "D", "Z"}
216
217 for _, priority := range validLegacyPriorities {
218 task := &Task{Priority: priority}
219 if !task.IsValidPriority() {
220 t.Errorf("Legacy priority %s should be valid", priority)
221 }
222 }
223
224 invalidLegacyPriorities := []string{"AA", "a", "1A", ""}
225 for _, priority := range invalidLegacyPriorities {
226 task := &Task{Priority: priority}
227 if priority != "" && task.IsValidPriority() {
228 t.Errorf("Legacy priority %s should be invalid", priority)
229 }
230 }
231 })
232
233 t.Run("Empty Priority Validation", func(t *testing.T) {
234 task := &Task{Priority: ""}
235 if !task.IsValidPriority() {
236 t.Error("Empty priority should be valid")
237 }
238 })
239
240 t.Run("Priority Weight Calculation", func(t *testing.T) {
241 tc := []struct {
242 priority string
243 weight int
244 }{
245 {PriorityHigh, 5},
246 {PriorityMedium, 4},
247 {PriorityLow, 3},
248 {"5", 5},
249 {"4", 4},
250 {"3", 3},
251 {"2", 2},
252 {"1", 1},
253 {"A", 26},
254 {"B", 25},
255 {"C", 24},
256 {"Z", 1},
257 {"", 0},
258 {"invalid", 0},
259 }
260
261 for _, tt := range tc {
262 task := &Task{Priority: tt.priority}
263 weight := task.GetPriorityWeight()
264 if weight != tt.weight {
265 t.Errorf("Priority %s: expected weight %d, got %d", tt.priority, tt.weight, weight)
266 }
267 }
268 })
269
270 t.Run("Priority Weight Ordering", func(t *testing.T) {
271 priorities := []string{PriorityHigh, PriorityMedium, PriorityLow}
272 weights := []int{}
273
274 for _, priority := range priorities {
275 task := &Task{Priority: priority}
276 weights = append(weights, task.GetPriorityWeight())
277 }
278
279 for i := 1; i < len(weights); i++ {
280 if weights[i-1] <= weights[i] {
281 t.Errorf("Priority weights should be in descending order: %v", weights)
282 }
283 }
284 })
285 })
286
287 t.Run("Tags Marshaling", func(t *testing.T) {
288 task := &Task{}
289
290 result, err := task.MarshalTags()
291 if err != nil {
292 t.Fatalf("MarshalTags failed: %v", err)
293 }
294 if result != "" {
295 t.Errorf("Expected empty string for empty tags, got '%s'", result)
296 }
297
298 task.Tags = []string{"work", "urgent", "project-x"}
299 result, err = task.MarshalTags()
300 if err != nil {
301 t.Fatalf("MarshalTags failed: %v", err)
302 }
303
304 expected := `["work","urgent","project-x"]`
305 if result != expected {
306 t.Errorf("Expected %s, got %s", expected, result)
307 }
308
309 newTask := &Task{}
310 err = newTask.UnmarshalTags(result)
311 if err != nil {
312 t.Fatalf("UnmarshalTags failed: %v", err)
313 }
314
315 if len(newTask.Tags) != 3 {
316 t.Errorf("Expected 3 tags, got %d", len(newTask.Tags))
317 }
318 if newTask.Tags[0] != "work" || newTask.Tags[1] != "urgent" || newTask.Tags[2] != "project-x" {
319 t.Errorf("Tags not unmarshaled correctly: %v", newTask.Tags)
320 }
321
322 emptyTask := &Task{}
323 err = emptyTask.UnmarshalTags("")
324 if err != nil {
325 t.Fatalf("UnmarshalTags with empty string failed: %v", err)
326 }
327 if emptyTask.Tags != nil {
328 t.Error("Expected nil tags for empty string")
329 }
330 })
331
332 t.Run("Annotations Marshaling", func(t *testing.T) {
333 task := &Task{}
334
335 result, err := task.MarshalAnnotations()
336 if err != nil {
337 t.Fatalf("MarshalAnnotations failed: %v", err)
338 }
339 if result != "" {
340 t.Errorf("Expected empty string for empty annotations, got '%s'", result)
341 }
342
343 task.Annotations = []string{"Note 1", "Note 2", "Important reminder"}
344 result, err = task.MarshalAnnotations()
345 if err != nil {
346 t.Fatalf("MarshalAnnotations failed: %v", err)
347 }
348
349 expected := `["Note 1","Note 2","Important reminder"]`
350 if result != expected {
351 t.Errorf("Expected %s, got %s", expected, result)
352 }
353
354 newTask := &Task{}
355 err = newTask.UnmarshalAnnotations(result)
356 if err != nil {
357 t.Fatalf("UnmarshalAnnotations failed: %v", err)
358 }
359
360 if len(newTask.Annotations) != 3 {
361 t.Errorf("Expected 3 annotations, got %d", len(newTask.Annotations))
362 }
363 if newTask.Annotations[0] != "Note 1" || newTask.Annotations[1] != "Note 2" || newTask.Annotations[2] != "Important reminder" {
364 t.Errorf("Annotations not unmarshaled correctly: %v", newTask.Annotations)
365 }
366
367 emptyTask := &Task{}
368 err = emptyTask.UnmarshalAnnotations("")
369 if err != nil {
370 t.Fatalf("UnmarshalAnnotations with empty string failed: %v", err)
371 }
372 if emptyTask.Annotations != nil {
373 t.Error("Expected nil annotations for empty string")
374 }
375 })
376
377 t.Run("IsStarted", func(t *testing.T) {
378 now := time.Now()
379 task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now}
380
381 if task.IsStarted() {
382 t.Errorf("expected IsStarted to be false, got true")
383 }
384 task.Start = &now
385 if !task.IsStarted() {
386 t.Errorf("expected IsStarted to be true, got false")
387 }
388 })
389
390 t.Run("HasDueDate and IsOverdue", func(t *testing.T) {
391 now := time.Now()
392 past := now.Add(-24 * time.Hour)
393 future := now.Add(24 * time.Hour)
394 task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now}
395
396 if task.HasDueDate() {
397 t.Errorf("expected HasDueDate to be false, got true")
398 }
399 task.Due = &future
400 if !task.HasDueDate() {
401 t.Errorf("expected HasDueDate to be true, got false")
402 }
403 task.Due = &past
404 task.Status = string(StatusPending)
405 if !task.IsOverdue(now) {
406 t.Errorf("expected overdue task, got false")
407 }
408 task.Status = string(StatusCompleted)
409 if task.IsOverdue(now) {
410 t.Errorf("expected completed task not to be overdue, got true")
411 }
412 })
413
414 t.Run("IsRecurring and IsRecurExpired", func(t *testing.T) {
415 now := time.Now()
416 past := now.Add(-24 * time.Hour)
417 future := now.Add(24 * time.Hour)
418
419 task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now}
420 if task.IsRecurring() {
421 t.Errorf("expected IsRecurring to be false, got true")
422 }
423 task.Recur = "FREQ=DAILY"
424 if !task.IsRecurring() {
425 t.Errorf("expected IsRecurring to be true, got false")
426 }
427 if task.IsRecurExpired(now) {
428 t.Errorf("expected IsRecurExpired to be false without Until, got true")
429 }
430 task.Until = &past
431 if !task.IsRecurExpired(now) {
432 t.Errorf("expected IsRecurExpired to be true, got false")
433 }
434 task.Until = &future
435 if task.IsRecurExpired(now) {
436 t.Errorf("expected IsRecurExpired to be false, got true")
437 }
438 })
439
440 t.Run("HasDependencies and Blocks", func(t *testing.T) {
441 now := time.Now()
442 task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now}
443 if task.HasDependencies() {
444 t.Errorf("expected HasDependencies to be false, got true")
445 }
446 task.DependsOn = []string{"abc"}
447 if !task.HasDependencies() {
448 t.Errorf("expected HasDependencies to be true, got false")
449 }
450 other := Task{UUID: "abc", DependsOn: []string{"123"}}
451 if !task.Blocks(&other) {
452 t.Errorf("expected task to block other, got false")
453 }
454 other.DependsOn = []string{}
455 if task.Blocks(&other) {
456 t.Errorf("expected task not to block other, got true")
457 }
458 })
459
460 t.Run("Urgency", func(t *testing.T) {
461 now := time.Now()
462 past := now.Add(-24 * time.Hour)
463
464 task := Task{
465 UUID: "u1",
466 Description: "urgency test",
467 Priority: "H",
468 Tags: []string{"t1"},
469 Due: &past,
470 Status: string(StatusPending),
471 Entry: now,
472 Modified: now,
473 }
474 score := task.Urgency(now)
475 if score <= 0 {
476 t.Errorf("expected positive urgency score, got %f", score)
477 }
478 })
479
480 })
481
482 t.Run("Movie Model", func(t *testing.T) {
483 t.Run("Status Methods", func(t *testing.T) {
484 tc := []struct {
485 status string
486 isWatched bool
487 isQueued bool
488 }{
489 {"queued", false, true},
490 {"watched", true, false},
491 {"removed", false, false},
492 {"unknown", false, false},
493 }
494
495 for _, tt := range tc {
496 movie := &Movie{Status: tt.status}
497
498 if movie.IsWatched() != tt.isWatched {
499 t.Errorf("Status %s: expected IsWatched %v, got %v", tt.status, tt.isWatched, movie.IsWatched())
500 }
501 if movie.IsQueued() != tt.isQueued {
502 t.Errorf("Status %s: expected IsQueued %v, got %v", tt.status, tt.isQueued, movie.IsQueued())
503 }
504 }
505 })
506 })
507
508 t.Run("TV Show Model", func(t *testing.T) {
509 t.Run("Status Methods", func(t *testing.T) {
510 tc := []struct {
511 status string
512 isWatching bool
513 isWatched bool
514 isQueued bool
515 }{
516 {"queued", false, false, true},
517 {"watching", true, false, false},
518 {"watched", false, true, false},
519 {"removed", false, false, false},
520 {"unknown", false, false, false},
521 }
522
523 for _, tt := range tc {
524 tvShow := &TVShow{Status: tt.status}
525
526 if tvShow.IsWatching() != tt.isWatching {
527 t.Errorf("Status %s: expected IsWatching %v, got %v", tt.status, tt.isWatching, tvShow.IsWatching())
528 }
529 if tvShow.IsWatched() != tt.isWatched {
530 t.Errorf("Status %s: expected IsWatched %v, got %v", tt.status, tt.isWatched, tvShow.IsWatched())
531 }
532 if tvShow.IsQueued() != tt.isQueued {
533 t.Errorf("Status %s: expected IsQueued %v, got %v", tt.status, tt.isQueued, tvShow.IsQueued())
534 }
535 }
536 })
537 })
538
539 t.Run("Book Model", func(t *testing.T) {
540 t.Run("Status Methods", func(t *testing.T) {
541 tc := []struct {
542 status string
543 isReading bool
544 isFinished bool
545 isQueued bool
546 }{
547 {"queued", false, false, true},
548 {"reading", true, false, false},
549 {"finished", false, true, false},
550 {"removed", false, false, false},
551 {"unknown", false, false, false},
552 }
553
554 for _, tt := range tc {
555 book := &Book{Status: tt.status}
556
557 if book.IsReading() != tt.isReading {
558 t.Errorf("Status %s: expected IsReading %v, got %v", tt.status, tt.isReading, book.IsReading())
559 }
560 if book.IsFinished() != tt.isFinished {
561 t.Errorf("Status %s: expected IsFinished %v, got %v", tt.status, tt.isFinished, book.IsFinished())
562 }
563 if book.IsQueued() != tt.isQueued {
564 t.Errorf("Status %s: expected IsQueued %v, got %v", tt.status, tt.isQueued, book.IsQueued())
565 }
566 }
567 })
568
569 t.Run("Progress Methods", func(t *testing.T) {
570 book := &Book{Progress: 75}
571
572 if book.ProgressPercent() != 75 {
573 t.Errorf("Expected progress 75%%, got %d%%", book.ProgressPercent())
574 }
575 })
576 })
577
578 t.Run("Note Model", func(t *testing.T) {
579 t.Run("Archive Methods", func(t *testing.T) {
580 note := &Note{Archived: false}
581
582 if note.IsArchived() {
583 t.Error("Note should not be archived")
584 }
585
586 note.Archived = true
587 if !note.IsArchived() {
588 t.Error("Note should be archived")
589 }
590 })
591
592 t.Run("Tags Marshaling", func(t *testing.T) {
593 note := &Note{}
594
595 result, err := note.MarshalTags()
596 if err != nil {
597 t.Fatalf("MarshalTags failed: %v", err)
598 }
599 if result != "" {
600 t.Errorf("Expected empty string for empty tags, got '%s'", result)
601 }
602
603 note.Tags = []string{"personal", "work", "idea"}
604 result, err = note.MarshalTags()
605 if err != nil {
606 t.Fatalf("MarshalTags failed: %v", err)
607 }
608
609 expected := `["personal","work","idea"]`
610 if result != expected {
611 t.Errorf("Expected %s, got %s", expected, result)
612 }
613
614 newNote := &Note{}
615 err = newNote.UnmarshalTags(result)
616 if err != nil {
617 t.Fatalf("UnmarshalTags failed: %v", err)
618 }
619
620 if len(newNote.Tags) != 3 {
621 t.Errorf("Expected 3 tags, got %d", len(newNote.Tags))
622 }
623 if newNote.Tags[0] != "personal" || newNote.Tags[1] != "work" || newNote.Tags[2] != "idea" {
624 t.Errorf("Tags not unmarshaled correctly: %v", newNote.Tags)
625 }
626
627 emptyNote := &Note{}
628 err = emptyNote.UnmarshalTags("")
629 if err != nil {
630 t.Fatalf("UnmarshalTags with empty string failed: %v", err)
631 }
632 if emptyNote.Tags != nil {
633 t.Error("Expected nil tags for empty string")
634 }
635 })
636
637 t.Run("Leaflet Association Methods", func(t *testing.T) {
638 t.Run("has no leaflet association by default", func(t *testing.T) {
639 note := &Note{}
640 if note.HasLeafletAssociation() {
641 t.Error("Note with nil leaflet_rkey should not have association")
642 }
643 })
644
645 t.Run("has leaflet association when rkey is set", func(t *testing.T) {
646 rkey := "test-rkey-123"
647 note := &Note{LeafletRKey: &rkey}
648
649 if !note.HasLeafletAssociation() {
650 t.Error("Note with leaflet_rkey should have association")
651 }
652 })
653
654 t.Run("is not published by default", func(t *testing.T) {
655 note := &Note{IsDraft: true}
656 if note.IsPublished() {
657 t.Error("Draft note should not be published")
658 }
659 })
660
661 t.Run("is published when has association and not draft", func(t *testing.T) {
662 rkey := "published-rkey"
663 note := &Note{
664 LeafletRKey: &rkey,
665 IsDraft: false,
666 }
667 if !note.IsPublished() {
668 t.Error("Note with leaflet association and not draft should be published")
669 }
670 })
671
672 t.Run("tracks publication metadata", func(t *testing.T) {
673 rkey := "test-rkey"
674 cid := "test-cid"
675 pubTime := time.Now()
676
677 note := &Note{
678 Title: "Test Note",
679 Content: "Test content",
680 LeafletRKey: &rkey,
681 LeafletCID: &cid,
682 PublishedAt: &pubTime,
683 IsDraft: false,
684 }
685
686 if !note.HasLeafletAssociation() {
687 t.Error("Note should have leaflet association")
688 }
689
690 if !note.IsPublished() {
691 t.Error("Note should be published")
692 }
693
694 if note.LeafletRKey == nil || *note.LeafletRKey != rkey {
695 t.Errorf("Expected rkey %s, got %v", rkey, note.LeafletRKey)
696 }
697
698 if note.LeafletCID == nil || *note.LeafletCID != cid {
699 t.Errorf("Expected cid %s, got %v", cid, note.LeafletCID)
700 }
701
702 if note.PublishedAt == nil || !note.PublishedAt.Equal(pubTime) {
703 t.Errorf("Expected published_at %v, got %v", pubTime, note.PublishedAt)
704 }
705 })
706
707 t.Run("handles draft status", func(t *testing.T) {
708 rkey := "draft-rkey"
709 note := &Note{
710 Title: "Draft Note",
711 Content: "Draft content",
712 LeafletRKey: &rkey,
713 IsDraft: true,
714 }
715
716 if !note.HasLeafletAssociation() {
717 t.Error("Draft should still have leaflet association")
718 }
719
720 if note.IsPublished() {
721 t.Error("Draft should not be published")
722 }
723 })
724 })
725 })
726
727 t.Run("Album Model", func(t *testing.T) {
728 t.Run("Rating Methods", func(t *testing.T) {
729 album := &Album{}
730
731 if album.HasRating() {
732 t.Error("Album with zero rating should return false for HasRating")
733 }
734
735 if album.IsValidRating() {
736 t.Error("Album with zero rating should return false for IsValidRating")
737 }
738
739 album.Rating = 3
740 if !album.HasRating() {
741 t.Error("Album with rating should return true for HasRating")
742 }
743
744 if !album.IsValidRating() {
745 t.Error("Album with valid rating should return true for IsValidRating")
746 }
747
748 for _, tc := range []struct {
749 rating int
750 isValid bool
751 }{{0, false}, {1, true}, {3, true}, {5, true}, {6, false}, {-1, false}} {
752 album.Rating = tc.rating
753 if album.IsValidRating() != tc.isValid {
754 t.Errorf("Rating %d: expected IsValidRating %v, got %v", tc.rating, tc.isValid, album.IsValidRating())
755 }
756 }
757 })
758
759 t.Run("Tracks Marshaling", func(t *testing.T) {
760 album := &Album{}
761
762 if result, err := album.MarshalTracks(); err != nil {
763 t.Fatalf("MarshalTracks failed: %v", err)
764 } else {
765 if result != "" {
766 t.Errorf("Expected empty string for empty tracks, got '%s'", result)
767 }
768 }
769
770 album.Tracks = []string{"Track 1", "Track 2", "Interlude"}
771 result, err := album.MarshalTracks()
772 if err != nil {
773 t.Fatalf("MarshalTracks failed: %v", err)
774 }
775
776 if expected := `["Track 1","Track 2","Interlude"]`; result != expected {
777 t.Errorf("Expected %s, got %s", expected, result)
778 }
779
780 newAlbum := &Album{}
781 if err = newAlbum.UnmarshalTracks(result); err != nil {
782 t.Fatalf("UnmarshalTracks failed: %v", err)
783 } else {
784 if len(newAlbum.Tracks) != 3 {
785 t.Errorf("Expected 3 tracks, got %d", len(newAlbum.Tracks))
786 }
787
788 if newAlbum.Tracks[0] != "Track 1" || newAlbum.Tracks[1] != "Track 2" || newAlbum.Tracks[2] != "Interlude" {
789 t.Errorf("Tracks not unmarshaled correctly: %v", newAlbum.Tracks)
790 }
791 }
792
793 emptyAlbum := &Album{}
794 if err = emptyAlbum.UnmarshalTracks(""); err != nil {
795 t.Fatalf("UnmarshalTracks with empty string failed: %v", err)
796 } else if emptyAlbum.Tracks != nil {
797 t.Error("Expected nil tracks for empty string")
798 }
799 })
800 })
801
802 t.Run("Article Model", func(t *testing.T) {
803 article := Article{URL: "", Author: "", Date: ""}
804 want := false
805
806 for _, tc := range []func() bool{article.HasAuthor, article.HasDate, article.IsValidURL} {
807 got := tc()
808 if got != want {
809 t.Errorf("wanted %v, got %v", want, got)
810 }
811 }
812
813 article.URL = "http//wikipedia.org"
814 if article.IsValidURL() != want {
815 t.Errorf("%v is invalid but got valid", article.URL)
816 }
817
818 article.URL = "http://wikipedia.org"
819 if !article.IsValidURL() {
820 t.Errorf("%v should be valid", article.URL)
821 }
822 })
823
824 t.Run("TimeEntry Model", func(t *testing.T) {
825 t.Run("IsActive", func(t *testing.T) {
826 now := time.Now()
827
828 t.Run("returns true when EndTime is nil", func(t *testing.T) {
829 te := &TimeEntry{
830 TaskID: 1,
831 StartTime: now,
832 EndTime: nil,
833 }
834
835 if !te.IsActive() {
836 t.Error("TimeEntry with nil EndTime should be active")
837 }
838 })
839
840 t.Run("returns false when EndTime is set", func(t *testing.T) {
841 endTime := now.Add(time.Hour)
842 te := &TimeEntry{
843 TaskID: 1,
844 StartTime: now,
845 EndTime: &endTime,
846 }
847
848 if te.IsActive() {
849 t.Error("TimeEntry with EndTime should not be active")
850 }
851 })
852 })
853
854 t.Run("Stop", func(t *testing.T) {
855 startTime := time.Now().Add(-time.Hour)
856 te := &TimeEntry{
857 TaskID: 1,
858 StartTime: startTime,
859 EndTime: nil,
860 Created: startTime,
861 Modified: startTime,
862 }
863
864 if !te.IsActive() {
865 t.Error("TimeEntry should be active before Stop()")
866 }
867
868 te.Stop()
869
870 if te.IsActive() {
871 t.Error("TimeEntry should not be active after Stop()")
872 }
873
874 if te.EndTime == nil {
875 t.Error("EndTime should be set after Stop()")
876 }
877
878 if te.EndTime.Before(startTime) {
879 t.Error("EndTime should be after StartTime")
880 }
881
882 expectedDuration := int64(te.EndTime.Sub(startTime).Seconds())
883 if te.DurationSeconds != expectedDuration {
884 t.Errorf("Expected DurationSeconds %d, got %d", expectedDuration, te.DurationSeconds)
885 }
886
887 if te.Modified.Before(startTime) {
888 t.Error("Modified time should be updated after Stop()")
889 }
890 })
891
892 t.Run("GetDuration", func(t *testing.T) {
893 startTime := time.Now().Add(-time.Hour)
894
895 t.Run("returns calculated duration when stopped", func(t *testing.T) {
896 endTime := startTime.Add(30 * time.Minute)
897 te := &TimeEntry{
898 TaskID: 1,
899 StartTime: startTime,
900 EndTime: &endTime,
901 DurationSeconds: 1800,
902 }
903
904 duration := te.GetDuration()
905 expectedDuration := 30 * time.Minute
906
907 if duration != expectedDuration {
908 t.Errorf("Expected duration %v, got %v", expectedDuration, duration)
909 }
910 })
911
912 t.Run("returns time since start when active", func(t *testing.T) {
913 te := &TimeEntry{
914 TaskID: 1,
915 StartTime: startTime,
916 EndTime: nil,
917 }
918
919 duration := te.GetDuration()
920
921 if duration < 59*time.Minute || duration > 61*time.Minute {
922 t.Errorf("Expected duration around 1 hour, got %v", duration)
923 }
924 })
925 })
926 })
927
928 t.Run("Error Handling", func(t *testing.T) {
929 t.Run("Marshaling Errors", func(t *testing.T) {
930 t.Run("UnmarshalTags handles invalid JSON", func(t *testing.T) {
931 task := &Task{}
932 if err := task.UnmarshalTags(`{"invalid": "json"}`); err == nil {
933 t.Error("Expected error for invalid JSON, got nil")
934 }
935 })
936
937 t.Run("UnmarshalAnnotations handles invalid JSON", func(t *testing.T) {
938 task := &Task{}
939 if err := task.UnmarshalAnnotations(`{"invalid": "json"}`); err == nil {
940 t.Error("Expected error for invalid JSON, got nil")
941 }
942 })
943 })
944 })
945
946 t.Run("Edge Cases", func(t *testing.T) {
947 t.Run("Task with nil slices", func(t *testing.T) {
948 task := &Task{
949 Tags: nil,
950 Annotations: nil,
951 }
952
953 if tagsJSON, err := task.MarshalTags(); err != nil {
954 t.Errorf("MarshalTags with nil slice failed: %v", err)
955 } else if tagsJSON != "" {
956 t.Errorf("Expected empty string for nil tags, got '%s'", tagsJSON)
957 }
958
959 if annotationsJSON, err := task.MarshalAnnotations(); err != nil {
960 t.Errorf("MarshalAnnotations with nil slice failed: %v", err)
961 } else if annotationsJSON != "" {
962 t.Errorf("Expected empty string for nil annotations, got '%s'", annotationsJSON)
963 }
964 })
965
966 t.Run("Models with zero values", func(t *testing.T) {
967 task := &Task{}
968 movie := &Movie{}
969 tvShow := &TVShow{}
970 book := &Book{}
971 note := &Note{}
972
973 if task.IsCompleted() || task.IsPending() || task.IsDeleted() {
974 t.Error("Zero value task should have false status methods")
975 }
976
977 if movie.IsWatched() || movie.IsQueued() {
978 t.Error("Zero value movie should have false status methods")
979 }
980
981 if tvShow.IsWatching() || tvShow.IsWatched() || tvShow.IsQueued() {
982 t.Error("Zero value TV show should have false status methods")
983 }
984
985 if book.IsReading() || book.IsFinished() || book.IsQueued() {
986 t.Error("Zero value book should have false status methods")
987 }
988
989 if book.ProgressPercent() != 0 {
990 t.Errorf("Zero value book should have 0%% progress, got %d%%", book.ProgressPercent())
991 }
992
993 if note.IsArchived() {
994 t.Error("Zero value note should not be archived")
995 }
996 })
997 })
998
999 t.Run("Behavior Interfaces", func(t *testing.T) {
1000 t.Run("Stateful Interface", func(t *testing.T) {
1001 t.Run("Task implements Stateful", func(t *testing.T) {
1002 task := &Task{Status: StatusTodo}
1003
1004 if task.GetStatus() != StatusTodo {
1005 t.Errorf("Expected status %s, got %s", StatusTodo, task.GetStatus())
1006 }
1007
1008 validStatuses := task.ValidStatuses()
1009 if len(validStatuses) == 0 {
1010 t.Error("ValidStatuses should not be empty")
1011 }
1012
1013 expectedStatuses := []string{StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned, StatusPending, StatusCompleted, StatusDeleted}
1014 if len(validStatuses) != len(expectedStatuses) {
1015 t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses))
1016 }
1017 })
1018
1019 t.Run("Book implements Stateful", func(t *testing.T) {
1020 book := &Book{Status: "reading"}
1021
1022 if book.GetStatus() != "reading" {
1023 t.Errorf("Expected status 'reading', got %s", book.GetStatus())
1024 }
1025
1026 validStatuses := book.ValidStatuses()
1027 expectedStatuses := []string{"queued", "reading", "finished", "removed"}
1028
1029 if len(validStatuses) != len(expectedStatuses) {
1030 t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses))
1031 }
1032
1033 for i, status := range expectedStatuses {
1034 if validStatuses[i] != status {
1035 t.Errorf("Expected status %s at index %d, got %s", status, i, validStatuses[i])
1036 }
1037 }
1038 })
1039
1040 t.Run("Movie implements Stateful", func(t *testing.T) {
1041 movie := &Movie{Status: "queued"}
1042
1043 if movie.GetStatus() != "queued" {
1044 t.Errorf("Expected status 'queued', got %s", movie.GetStatus())
1045 }
1046
1047 validStatuses := movie.ValidStatuses()
1048 expectedStatuses := []string{"queued", "watched", "removed"}
1049
1050 if len(validStatuses) != len(expectedStatuses) {
1051 t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses))
1052 }
1053 })
1054
1055 t.Run("TVShow implements Stateful", func(t *testing.T) {
1056 tvShow := &TVShow{Status: "watching"}
1057
1058 if tvShow.GetStatus() != "watching" {
1059 t.Errorf("Expected status 'watching', got %s", tvShow.GetStatus())
1060 }
1061
1062 validStatuses := tvShow.ValidStatuses()
1063 expectedStatuses := []string{"queued", "watching", "watched", "removed"}
1064
1065 if len(validStatuses) != len(expectedStatuses) {
1066 t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses))
1067 }
1068 })
1069 })
1070
1071 t.Run("Completable Interface", func(t *testing.T) {
1072 t.Run("Book implements Completable", func(t *testing.T) {
1073 now := time.Now()
1074
1075 unfinishedBook := &Book{Status: "reading"}
1076 if unfinishedBook.IsCompleted() {
1077 t.Error("Book with 'reading' status should not be completed")
1078 }
1079 if unfinishedBook.GetCompletionTime() != nil {
1080 t.Error("Unfinished book should have nil completion time")
1081 }
1082
1083 finishedBook := &Book{Status: "finished", Finished: &now}
1084 if !finishedBook.IsCompleted() {
1085 t.Error("Book with 'finished' status should be completed")
1086 }
1087 if finishedBook.GetCompletionTime() == nil {
1088 t.Error("Finished book should have completion time")
1089 }
1090 if !finishedBook.GetCompletionTime().Equal(now) {
1091 t.Errorf("Expected completion time %v, got %v", now, finishedBook.GetCompletionTime())
1092 }
1093 })
1094
1095 t.Run("Movie implements Completable", func(t *testing.T) {
1096 now := time.Now()
1097
1098 unwatchedMovie := &Movie{Status: "queued"}
1099 if unwatchedMovie.IsCompleted() {
1100 t.Error("Movie with 'queued' status should not be completed")
1101 }
1102 if unwatchedMovie.GetCompletionTime() != nil {
1103 t.Error("Unwatched movie should have nil completion time")
1104 }
1105
1106 watchedMovie := &Movie{Status: "watched", Watched: &now}
1107 if !watchedMovie.IsCompleted() {
1108 t.Error("Movie with 'watched' status should be completed")
1109 }
1110 if watchedMovie.GetCompletionTime() == nil {
1111 t.Error("Watched movie should have completion time")
1112 }
1113 if !watchedMovie.GetCompletionTime().Equal(now) {
1114 t.Errorf("Expected completion time %v, got %v", now, watchedMovie.GetCompletionTime())
1115 }
1116 })
1117
1118 t.Run("TVShow implements Completable", func(t *testing.T) {
1119 now := time.Now()
1120
1121 unwatchedShow := &TVShow{Status: "watching"}
1122 if unwatchedShow.IsCompleted() {
1123 t.Error("TVShow with 'watching' status should not be completed")
1124 }
1125 if unwatchedShow.GetCompletionTime() != nil {
1126 t.Error("Unwatched show should have nil completion time")
1127 }
1128
1129 watchedShow := &TVShow{Status: "watched", LastWatched: &now}
1130 if !watchedShow.IsCompleted() {
1131 t.Error("TVShow with 'watched' status should be completed")
1132 }
1133 if watchedShow.GetCompletionTime() == nil {
1134 t.Error("Watched show should have completion time")
1135 }
1136 if !watchedShow.GetCompletionTime().Equal(now) {
1137 t.Errorf("Expected completion time %v, got %v", now, watchedShow.GetCompletionTime())
1138 }
1139 })
1140 })
1141
1142 t.Run("Progressable Interface", func(t *testing.T) {
1143 t.Run("Book implements Progressable", func(t *testing.T) {
1144 book := &Book{Progress: 50}
1145
1146 if book.GetProgress() != 50 {
1147 t.Errorf("Expected progress 50, got %d", book.GetProgress())
1148 }
1149 })
1150
1151 t.Run("SetProgress with valid values", func(t *testing.T) {
1152 book := &Book{}
1153
1154 if err := book.SetProgress(0); err != nil {
1155 t.Errorf("SetProgress(0) should succeed, got error: %v", err)
1156 }
1157 if book.Progress != 0 {
1158 t.Errorf("Expected progress 0, got %d", book.Progress)
1159 }
1160
1161 if err := book.SetProgress(100); err != nil {
1162 t.Errorf("SetProgress(100) should succeed, got error: %v", err)
1163 }
1164 if book.Progress != 100 {
1165 t.Errorf("Expected progress 100, got %d", book.Progress)
1166 }
1167
1168 if err := book.SetProgress(42); err != nil {
1169 t.Errorf("SetProgress(42) should succeed, got error: %v", err)
1170 }
1171 if book.Progress != 42 {
1172 t.Errorf("Expected progress 42, got %d", book.Progress)
1173 }
1174 })
1175
1176 t.Run("SetProgress rejects invalid values", func(t *testing.T) {
1177 book := &Book{Progress: 50}
1178
1179 if err := book.SetProgress(-1); err == nil {
1180 t.Error("SetProgress(-1) should fail")
1181 } else if book.Progress != 50 {
1182 t.Error("Progress should not change on validation error")
1183 }
1184
1185 if err := book.SetProgress(101); err == nil {
1186 t.Error("SetProgress(101) should fail")
1187 } else if book.Progress != 50 {
1188 t.Error("Progress should not change on validation error")
1189 }
1190
1191 if err := book.SetProgress(-100); err == nil {
1192 t.Error("SetProgress(-100) should fail")
1193 }
1194
1195 if err := book.SetProgress(1000); err == nil {
1196 t.Error("SetProgress(1000) should fail")
1197 }
1198 })
1199
1200 t.Run("SetProgress error messages", func(t *testing.T) {
1201 book := &Book{}
1202
1203 err := book.SetProgress(-5)
1204 if err == nil {
1205 t.Fatal("Expected error for negative progress")
1206 }
1207 if err.Error() != "progress must be between 0 and 100, got -5" {
1208 t.Errorf("Unexpected error message: %s", err.Error())
1209 }
1210
1211 err = book.SetProgress(150)
1212 if err == nil {
1213 t.Fatal("Expected error for progress > 100")
1214 }
1215 if err.Error() != "progress must be between 0 and 100, got 150" {
1216 t.Errorf("Unexpected error message: %s", err.Error())
1217 }
1218 })
1219 })
1220 })
1221}