domain_test.go (5362B)
1 package domain 2 3 import ( 4 "strings" 5 "testing" 6 "time" 7 8 "git.ryansepassi.com/git/gotextlog.git/internal/textlog" 9 ) 10 11 func TestNoteValidationTimeAndRedaction(t *testing.T) { 12 if got := CodePointLength("🌱"); got != 1 { 13 t.Fatalf("CodePointLength = %d, want 1", got) 14 } 15 tests := []struct { 16 body string 17 want string 18 }{ 19 {"", "Write something first"}, 20 {" \t\n", "Write something first"}, 21 {"\uFEFF", "Write something first"}, 22 {strings.Repeat("🌱", 281), "Notes can be at most 280 characters"}, 23 {strings.Repeat("x\n", 10) + "x", "Notes can be at most 10 lines"}, 24 {"hello", ""}, 25 } 26 for _, test := range tests { 27 if got := ValidatePost(test.body); got != test.want { 28 t.Errorf("ValidatePost(%q) = %q, want %q", test.body, got, test.want) 29 } 30 } 31 32 now := time.Date(2026, 1, 31, 0, 2, 0, 0, time.UTC) 33 for input, want := range map[string]string{ 34 "2026-01-31T00:01:45Z": "15s", 35 "2026-01-30T23:00:00Z": "1h", 36 "2026-01-29T00:02:00Z": "2d", 37 "2026-01-01T00:00:00Z": "2026-01-01", 38 "2026-02-01T00:00:00Z": "0s", 39 } { 40 if got := RelativeTime(input, now); got != want { 41 t.Errorf("RelativeTime(%q) = %q, want %q", input, got, want) 42 } 43 } 44 if got := Redact("secret + secret", "secret"); got != "[redacted] + [redacted]" { 45 t.Fatalf("Redact = %q", got) 46 } 47 if got := Redact("unchanged", ""); got != "unchanged" { 48 t.Fatalf("Redact without token = %q", got) 49 } 50 } 51 52 func TestRichTextAndTerminalSafety(t *testing.T) { 53 body := "See [small PRs](getsmall.xyz/post/one), [Anthropic Risk August 2026 \\[pdf\\]](www-cdn.anthropic.com/report.pdf), https://textlog.cc and `example.com` with @david and #small_web." 54 tokens := TokenizeRichText(body) 55 wantInteractive := []RichToken{ 56 {Kind: TokenLink, Text: "small PRs", URL: "https://getsmall.xyz/post/one"}, 57 {Kind: TokenLink, Text: "Anthropic Risk August 2026 [pdf]", URL: "https://www-cdn.anthropic.com/report.pdf"}, 58 {Kind: TokenLink, Text: "https://textlog.cc", URL: "https://textlog.cc"}, 59 {Kind: TokenCode, Text: "`example.com`"}, 60 {Kind: TokenReference, Text: "@david"}, 61 {Kind: TokenReference, Text: "#small_web"}, 62 } 63 var interactive []RichToken 64 for _, token := range tokens { 65 if token.Kind != TokenText { 66 interactive = append(interactive, token) 67 } 68 } 69 if len(interactive) != len(wantInteractive) { 70 t.Fatalf("tokens = %#v", tokens) 71 } 72 for i := range wantInteractive { 73 if interactive[i] != wantInteractive[i] { 74 t.Errorf("interactive token %d = %#v, want %#v", i, interactive[i], wantInteractive[i]) 75 } 76 } 77 var rebuilt strings.Builder 78 for _, token := range tokens { 79 rebuilt.WriteString(token.Text) 80 } 81 if strings.Contains(rebuilt.String(), "\\[") || !strings.Contains(rebuilt.String(), "[pdf]") { 82 t.Fatalf("rendered token text did not unescape markdown label: %q", rebuilt.String()) 83 } 84 85 if got, want := TerminalSafeText("A\tB\r\n /\\\r | |\nyes 🙌🏻!"), "A B\n /\\\n | |\nyes 🙌!"; got != want { 86 t.Fatalf("TerminalSafeText = %q, want %q", got, want) 87 } 88 if got, want := TerminalSafeText("123\tX\n1234\tX"), "123 X\n1234 X"; got != want { 89 t.Fatalf("TerminalSafeText tab stops = %q, want %q", got, want) 90 } 91 if got := TokenizeRichText("https://one.example\u00a0https://two.example"); len(got) != 3 || got[1].Kind != TokenText { 92 t.Fatalf("TokenizeRichText did not stop links at JavaScript whitespace: %#v", got) 93 } 94 95 if !IsASCIIArt("`literal` example.com", []string{"#ASCII"}) || 96 !IsASCIIArt("art\n#ascii_art", nil) || 97 IsASCIIArt("ordinary text", []string{"design"}) { 98 t.Fatal("IsASCIIArt did not recognize supported tags") 99 } 100 101 for _, test := range []struct{ current, length, want int }{ 102 {-2, 4, 0}, {2, 4, 2}, {8, 4, 3}, {1, 0, 0}, 103 } { 104 if got := ClampSelection(test.current, test.length); got != test.want { 105 t.Errorf("ClampSelection(%d, %d) = %d, want %d", test.current, test.length, got, test.want) 106 } 107 } 108 } 109 110 func TestBuildReplyThreadDepthFirstWithChronologicalSiblingsOrphansAndCycles(t *testing.T) { 111 root := post(1, nil, 0) 112 replies := []textlog.Reply{ 113 reply(2, intPtr(1), 3, 1), 114 reply(3, intPtr(1), 0, 1), 115 reply(4, intPtr(2), 2, 2), 116 } 117 got := BuildReplyThread(root, replies) 118 assertThread(t, got, []int{1, 2, 4, 3}, []int{0, 1, 2, 1}, []int{0, 0, 2, 0}) 119 120 malformed := []textlog.Reply{ 121 reply(7, intPtr(8), 0, 0), 122 reply(8, intPtr(7), 0, 2), 123 reply(9, intPtr(99), 4, 0), 124 reply(7, intPtr(1), 0, 1), // duplicate ID must not be emitted again. 125 } 126 got = BuildReplyThread(root, malformed) 127 assertThread(t, got, []int{1, 7, 8, 9}, []int{0, 1, 2, 1}, []int{0, 0, 0, 4}) 128 } 129 130 func post(id int, parentID *int, replyCount int) textlog.Post { 131 return textlog.Post{ID: id, ParentID: parentID, Body: string(rune('0' + id)), ReplyCount: replyCount} 132 } 133 134 func reply(id int, parentID *int, replyCount, depth int) textlog.Reply { 135 return textlog.Reply{Post: post(id, parentID, replyCount), Depth: depth} 136 } 137 138 func intPtr(value int) *int { return &value } 139 140 func assertThread(t *testing.T, got []ThreadItem, ids, depths, more []int) { 141 t.Helper() 142 if len(got) != len(ids) { 143 t.Fatalf("thread length = %d, want %d: %#v", len(got), len(ids), got) 144 } 145 for i := range got { 146 if got[i].Post.ID != ids[i] || got[i].Depth != depths[i] || got[i].MoreCount != more[i] { 147 t.Errorf("thread[%d] = {id:%d depth:%d more:%d}, want {id:%d depth:%d more:%d}", 148 i, got[i].Post.ID, got[i].Depth, got[i].MoreCount, ids[i], depths[i], more[i]) 149 } 150 } 151 }