gotextlog

gotextlog
git clone https://git.ryansepassi.com/git/gotextlog.git
Log | Files | Refs | README

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 }