summaryrefslogtreecommitdiff
path: root/vrsite/permalink_test.go
blob: c3d572f5c03d5a655c1a29c33c510bea89e85374 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
package main

import (
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
)

func TestNoteByIDSpansTheWholeLog(t *testing.T) {
	entries := parseLog([]byte(logLines(noteA, noteB)))
	full := buildModel("test", entries, "")
	if n := full.NoteByID(2); n == nil || n.Text != "entry point does too much" {
		t.Fatalf("NoteByID(2) = %+v", n)
	}
	if n := full.NoteByID(99); n != nil {
		t.Errorf("NoteByID(99) = %+v, want nil", n)
	}
	// ids are counted over the whole log, so a link keeps meaning the same
	// note when the site is scoped to one change — that is the whole point of
	// a permalink
	scoped := buildModel("test", entries, "aaa")
	for _, id := range []int{1, 2} {
		a, b := full.NoteByID(id), scoped.NoteByID(id)
		if a == nil || b == nil || a.Text != b.Text {
			t.Errorf("note %d differs between scopes: %+v vs %+v", id, a, b)
		}
	}
}

func TestPermaHref(t *testing.T) {
	n := &Note{ID: 7}
	if got := (&page{Live: true, Root: "/"}).PermaHref(n); got != "/note/7" {
		t.Errorf("live permalink = %q", got)
	}
	// a permalink names nothing but the note: no rev, no scope, so it means
	// the same thing wherever it is pasted
	wide := &page{Live: true, Root: "/", Rev: "xyz", Scope: "all"}
	if got := wide.PermaHref(n); got != "/note/7" {
		t.Errorf("permalink from a scoped view = %q", got)
	}
	static := &page{Root: "", Site: &Site{Notes: []*Note{n}}}
	if got := static.PermaHref(n); got != "note/7.html" {
		t.Errorf("static permalink = %q", got)
	}
	// on the note's own page there is nowhere to go
	if got := (&page{Live: true, Root: "/", Kind: "note"}).PermaHref(n); got != "" {
		t.Errorf("self permalink = %q, want none", got)
	}
	// an export holding another change has no page to point at
	empty := &page{Root: "", Site: &Site{}}
	if got := empty.PermaHref(n); got != "" {
		t.Errorf("permalink into an export without the note = %q, want none", got)
	}
}

func TestExcerptShowsTheNotedLinesInContext(t *testing.T) {
	hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
	var b strings.Builder
	for i := 1; i <= 60; i++ {
		b.WriteString("line\n")
	}
	src := []byte(b.String())

	body, from, to := excerptFor("x.txt", src, nil, hl, &Note{ID: 3, Start: 20, End: 24})
	if from != 20-excerptContext || to != 24+excerptContext {
		t.Errorf("range = %d–%d, want %d–%d", from, to, 20-excerptContext, 24+excerptContext)
	}
	got := string(body)
	// real line numbers, not the excerpt's own
	if !strings.Contains(got, `id="L16"`) || !strings.Contains(got, `id="L28"`) {
		t.Errorf("excerpt is not numbered from the file:\n%s", got)
	}
	if strings.Contains(got, `id="L15"`) || strings.Contains(got, `id="L29"`) {
		t.Errorf("excerpt reaches past its context:\n%s", got)
	}
	// only the noted lines are marked, and they carry the note's anchor
	for _, want := range []string{`<li id="L20"`, `class="noted"`, `data-note="note-3"`} {
		if !strings.Contains(got, want) {
			t.Errorf("missing %q in:\n%s", want, got)
		}
	}
	if n := strings.Count(got, "noted"); n != 5 {
		t.Errorf("marked %d lines, want 5 (20–24)", n)
	}

	// context is clamped to the file, never past either end
	if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 2, End: 2}); from != 1 {
		t.Errorf("excerpt from = %d, want 1", from)
	}
	if _, from, to = excerptFor("x.txt", src, nil, hl, &Note{Start: 59, End: 59}); to != 60 {
		t.Errorf("excerpt to = %d, want 60", to)
	}
	// a note pinned to no line has nothing to excerpt
	if body, _, _ := excerptFor("x.txt", src, nil, hl, &Note{}); body != "" {
		t.Errorf("whole-file note produced an excerpt: %s", body)
	}
}

// content-visibility: auto brings style containment with it, and a contained
// element's own counter-reset comes out as 0 whatever value it names. Every
// chunk after the first would then count from 1, so the number a line shows
// has to be set on the line itself.
func TestChunksAreNumberedFromTheirFirstLine(t *testing.T) {
	hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
	var b strings.Builder
	for i := 1; i <= chunkLines*2+10; i++ {
		b.WriteString("x\n")
	}
	f := &File{Path: "big.txt", NoteAt: map[int]int{}}
	f.render([]byte(b.String()), hl)
	got := string(f.Body)

	if strings.Contains(got, "counter-reset") {
		t.Error("a chunk still resets the counter on the <ol>, which containment ignores")
	}
	for _, ln := range []int{1, chunkLines + 1, chunkLines*2 + 1} {
		want := `<li id="L` + strconv.Itoa(ln) + `" style="counter-set: ln ` + strconv.Itoa(ln) + `"`
		if !strings.Contains(got, want) {
			t.Errorf("chunk starting at line %d does not set its number (want %q)", ln, want)
		}
	}
	if n := strings.Count(got, "counter-set"); n != 3 {
		t.Errorf("%d lines set the counter, want 3 — one per chunk", n)
	}
}

// The permalink is the link handed to someone else, so it has to open on its
// own: the note, and the code it is about, at the change it was written at.
func TestNotePermalinkOverHTTP(t *testing.T) {
	sv, _, _ := testServer(t)
	h := sv.handler()

	// testServer's log holds one note, on hello.txt:2
	w := get(t, h, "/note/1", false)
	if w.Code != 200 {
		t.Fatalf("GET /note/1 = %d: %s", w.Code, w.Body)
	}
	body := w.Body.String()
	for _, want := range []string{
		"second line matters", // the note
		`data-kind="note"`,    // and its own page, not a list
		`<li id="L2"`,         // the line it is about…
		"two",                 // …with the code on it
		`<li id="L1"`,         // in context
		"open the whole file",
	} {
		if !strings.Contains(body, want) {
			t.Errorf("permalink page is missing %q", want)
		}
	}
	// the card does not link to the page it is already on
	if strings.Contains(body, `class="perma" href`) {
		t.Error("the note links to its own page")
	}

	// and the notes list offers the link in the first place
	if list := get(t, h, "/notes", false).Body.String(); !strings.Contains(list, `class="perma" href="/note/1"`) {
		t.Error("the notes page offers no permalink")
	}

	for _, path := range []string{"/note/999", "/note/abc", "/note/0"} {
		if code := get(t, h, path, false).Code; code != 404 {
			t.Errorf("GET %s = %d, want 404", path, code)
		}
	}
}

func TestStaticExportWritesNotePages(t *testing.T) {
	sv, logPath, change := testServer(t)
	extra := logLines(
		`{"time":"2026-07-20T10:02:00Z","op":"note","session":"s2","agent":"claude","change":"`+change+`","note_id":1,"reply_to":1,"text":"static child"}`,
		`{"time":"2026-07-20T10:03:00Z","op":"note","session":"s3","agent":"codex","change":"`+change+`","note_id":1,"text":"[static mention](note/1)"}`,
	)
	if err := os.WriteFile(logPath, append(mustRead(t, logPath), extra...), 0o644); err != nil {
		t.Fatal(err)
	}
	site, _, _ := sv.snap()
	out := t.TempDir()
	writeSite(site, out, sv.repo, testThemes(sv.theme, sv.light))

	b, err := os.ReadFile(filepath.Join(out, "note", "1.html"))
	if err != nil {
		t.Fatalf("no page for note 1: %v", err)
	}
	page := string(b)
	for _, want := range []string{"second line matters", `<li id="L2"`, "two",
		`href="../code/hello.txt.html?note=1#L2"`, "static child", "static mention",
		`class="threadnode via-reply"`, `class="threadnode via-mention"`,
		`href="../note/1.html"`} {
		if !strings.Contains(page, want) {
			t.Errorf("exported note page is missing %q", want)
		}
	}
	// the links out have to be relative, since an export is opened off a disk
	if strings.Contains(page, `href="/`) {
		t.Errorf("exported note page carries an absolute link:\n%s", page)
	}
	if strings.Contains(page, `class="replybox"`) {
		t.Error("the static thread offers a reply form without a server")
	}
	notes, err := os.ReadFile(filepath.Join(out, "notes.html"))
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(notes), `class="perma" href="note/1.html"`) {
		t.Error("the exported notes page offers no permalink")
	}
}