package web
import (
"strings"
"testing"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// render is renderDocDiff for a document nobody has commented on, which is what
// the presentation tests are about.
func render(oldSrc, newSrc []byte) diffView {
return renderDocDiff(docDiff{DocID: "SPEC-0007", Path: "specs/0007-storage.md", Base: oldSrc, Proposed: newSrc})
}
// blockIDs pulls the rendered block ids out of a diff, in document order, so a
// test can compare two renders without depending on the digest itself.
func blockIDs(html string) []string {
var ids []string
for _, rest := range strings.Split(html, ` id="b-`)[1:] {
ids = append(ids, "b-"+rest[:strings.IndexByte(rest, '"')])
}
return ids
}
// TestRenderDocDiffUnchanged proves a proposal that does not change a document
// is reported as unchanged rather than as an empty diff.
func TestRenderDocDiffUnchanged(t *testing.T) {
src := []byte("# Title\n\nOne paragraph.\n")
v := render(src, src)
if !v.Unchanged {
t.Fatalf("Unchanged = false, want true for identical input")
}
if v.HTML != "" {
t.Errorf("HTML = %q, want empty for an unchanged document", v.HTML)
}
}
// TestRenderDocDiffInlineWordChange proves a small edit renders inline with
// <del>/<ins> marks and not as two columns.
func TestRenderDocDiffInlineWordChange(t *testing.T) {
old := []byte("# Title\n\nThe quick brown fox jumps over the lazy dog.\n")
nw := []byte("# Title\n\nThe quick red fox jumps over the lazy dog.\n")
v := render(old, nw)
if v.Unchanged {
t.Fatalf("Unchanged = true, want a change")
}
html := string(v.HTML)
if !strings.Contains(html, "<del>brown</del>") {
t.Errorf("missing inline deletion of 'brown'; got:\n%s", html)
}
if !strings.Contains(html, "<ins>red</ins>") {
t.Errorf("missing inline insertion of 'red'; got:\n%s", html)
}
if strings.Contains(html, "ph-columns") {
t.Errorf("a one-word edit rendered as two columns; got:\n%s", html)
}
}
// TestRenderDocDiffTwoColumnBelowThreshold proves a block rewritten enough to
// fall below the similarity threshold renders as the two-column old/new view —
// the Phase 0 verdict's hard requirement.
func TestRenderDocDiffTwoColumnBelowThreshold(t *testing.T) {
// A block rewritten to ~0.58 similarity: paired as a modify (above the 0.40
// pairing floor) but shredded enough to fall below the 0.75 inline switch.
old := []byte("# Title\n\nThe committee approved the annual budget after a long and " +
"contentious debate that lasted well into the evening.\n")
nw := []byte("# Title\n\nThe committee rejected the annual budget after a brief and " +
"quiet discussion that ended early in the afternoon.\n")
v := render(old, nw)
html := string(v.HTML)
if !strings.Contains(html, "ph-columns") {
t.Fatalf("a wholesale rewrite did not render as two columns; got:\n%s", html)
}
if !strings.Contains(html, "ph-col ph-old") || !strings.Contains(html, "ph-col ph-new") {
t.Errorf("two-column view missing an old or new column; got:\n%s", html)
}
}
// TestRenderDocDiffEscapesContent proves document content is HTML-escaped: a
// document that contains markup cannot inject it into the review page.
func TestRenderDocDiffEscapesContent(t *testing.T) {
old := []byte("# Title\n\nplain text here.\n")
nw := []byte("# Title\n\nplain <script>alert(1)</script> text here.\n")
v := render(old, nw)
html := string(v.HTML)
if strings.Contains(html, "<script>") {
t.Fatalf("unescaped <script> reached the output; got:\n%s", html)
}
if !strings.Contains(html, "<script>") {
t.Errorf("expected the escaped script tag in the output; got:\n%s", html)
}
}
// TestRenderDocDiffEscapesUnchangedContent proves the context blocks the review
// now renders are escaped too — both the collapsed preview and the body.
func TestRenderDocDiffEscapesUnchangedContent(t *testing.T) {
const untouched = "A <b>bold</b> claim nobody edited.\n"
old := []byte("# Title\n\n" + untouched + "\nfirst.\n")
nw := []byte("# Title\n\n" + untouched + "\nsecond.\n")
html := string(render(old, nw).HTML)
if strings.Contains(html, "<b>bold</b>") {
t.Fatalf("unescaped markup from an unchanged block reached the output; got:\n%s", html)
}
if want := strings.Count(html, "<b>bold</b>"); want != 2 {
t.Errorf("escaped markup appears %d times, want 2 (the preview and the body); got:\n%s", want, html)
}
}
// TestRenderDocDiffInsertAndDelete proves an added and a removed block are each
// shown whole, labelled.
func TestRenderDocDiffInsertAndDelete(t *testing.T) {
old := []byte("# Title\n\nKept paragraph.\n\nDoomed paragraph.\n")
nw := []byte("# Title\n\nKept paragraph.\n\nBrand new paragraph.\n")
v := render(old, nw)
html := string(v.HTML)
if !strings.Contains(html, "ph-insert") {
t.Errorf("missing an inserted block; got:\n%s", html)
}
if !strings.Contains(html, "ph-delete") {
t.Errorf("missing a deleted block; got:\n%s", html)
}
}
// TestUnchangedBlocksRenderAsSubordinateContext proves the reversal this phase
// makes: an unchanged block is on the page — every block of a proposed document
// must be commentable — but as collapsed context, so a changed block still reads
// as the thing that changed.
func TestUnchangedBlocksRenderAsSubordinateContext(t *testing.T) {
old := []byte("# Title\n\nUntouched paragraph.\n\nOld wording here.\n")
nw := []byte("# Title\n\nUntouched paragraph.\n\nNew wording here.\n")
html := string(render(old, nw).HTML)
if !strings.Contains(html, "Untouched paragraph.") {
t.Fatalf("the unchanged block is not on the page; got:\n%s", html)
}
if !strings.Contains(html, `<details class="ph-block ph-context"`) {
t.Errorf("the unchanged block is not rendered as collapsed context; got:\n%s", html)
}
// The changed block must stay plainly changed: its own class, its inline
// marks, and no <details> wrapper hiding it behind a click.
if !strings.Contains(html, `<div class="ph-block ph-modify"`) {
t.Errorf("the changed block lost its own presentation; got:\n%s", html)
}
if !strings.Contains(html, "<ins>New</ins>") {
t.Errorf("the changed block lost its inline marks; got:\n%s", html)
}
}
// TestEveryRenderedBlockCarriesAnID proves a comment has something to point at
// and a link has something to scroll to, on changed and unchanged blocks alike.
func TestEveryRenderedBlockCarriesAnID(t *testing.T) {
old := []byte("# Title\n\nUntouched paragraph.\n\nOld wording here.\n")
nw := []byte("# Title\n\nUntouched paragraph.\n\nNew wording here.\n")
html := string(render(old, nw).HTML)
ids := blockIDs(html)
// heading, untouched paragraph, modified paragraph.
if len(ids) != 3 {
t.Fatalf("rendered %d block ids, want 3; got:\n%s", len(ids), html)
}
seen := map[string]bool{}
for _, id := range ids {
if seen[id] {
t.Errorf("duplicate block id %q; got:\n%s", id, html)
}
seen[id] = true
}
}
// TestBlockIDIsStableAcrossARerender proves the id is a function of the block,
// not of anything the renderer accumulates as it goes.
func TestBlockIDIsStableAcrossARerender(t *testing.T) {
old := []byte("# Title\n\nUntouched paragraph.\n\nOld wording here.\n")
nw := []byte("# Title\n\nUntouched paragraph.\n\nNew wording here.\n")
first := blockIDs(string(render(old, nw).HTML))
second := blockIDs(string(render(old, nw).HTML))
if len(first) == 0 || !equalStrings(first, second) {
t.Fatalf("ids changed across a re-render:\n%v\n%v", first, second)
}
}
// TestBlockIDSurvivesAnUnrelatedEdit proves an id names its own block: editing
// a different section of the document leaves it alone, so a link written down
// yesterday still lands on the paragraph it was written for.
func TestBlockIDSurvivesAnUnrelatedEdit(t *testing.T) {
const base = "# Storage\n\nThe block under test.\n\n## Trade-offs\n\nSomething else entirely.\n"
const first = base + "\nA tail paragraph.\n"
const second = "# Storage\n\nThe block under test.\n\n## Trade-offs\n\nSomething else, rewritten.\n\nA tail paragraph.\n"
before := blockIDs(string(render([]byte(base), []byte(first)).HTML))
after := blockIDs(string(render([]byte(base), []byte(second)).HTML))
if len(before) < 2 || len(after) < 2 {
t.Fatalf("expected several blocks, got %d and %d", len(before), len(after))
}
// Index 1 is "The block under test." in both renders; the edit is two blocks
// further down, under a different heading.
if before[1] != after[1] {
t.Errorf("an edit elsewhere in the document changed a block's id: %q vs %q", before[1], after[1])
}
}
// TestThreadRendersOnItsOwnBlock proves an anchored comment is drawn against
// the block it anchors to and nowhere else.
func TestThreadRendersOnItsOwnBlock(t *testing.T) {
const src = "# Storage\n\nThe commented paragraph.\n\nAn innocent bystander.\n"
anchor, err := service.AnchorOf("SPEC-0007", []byte(src), 1, core.SideNew)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
view := renderDocDiff(docDiff{
DocID: "SPEC-0007", Path: "specs/a.md",
Base: []byte("# Storage\n\nThe commented paragraph.\n"),
Proposed: []byte(src),
Threads: []service.Thread{{
Root: service.Comment{ID: 4, Author: "bigbes", Body: "this is wrong"},
Anchor: anchor, State: core.AnchorExact, Block: 1,
}},
})
html := string(view.HTML)
if len(view.Unplaced) != 0 {
t.Fatalf("an anchored thread came back unplaced: %+v", view.Unplaced)
}
// The comment must sit inside the block it anchors to: the bystander block
// opens after it, so the comment body has to appear before that boundary.
comment := strings.Index(html, "this is wrong")
bystander := strings.Index(html, "An innocent bystander")
if comment < 0 {
t.Fatalf("the comment is not on the page; got:\n%s", html)
}
if bystander > 0 && comment > bystander {
t.Errorf("the comment rendered after the following block, not on its own; got:\n%s", html)
}
}
// TestOutdatedThreadIsHandedBackNotAttached proves a comment whose anchor is
// lost is never drawn against some other block. It comes back for the page to
// show in its own area — visible, and attached to nothing.
func TestOutdatedThreadIsHandedBackNotAttached(t *testing.T) {
view := renderDocDiff(docDiff{
DocID: "SPEC-0007", Path: "specs/a.md",
Base: []byte("# Storage\n\nOne paragraph.\n"),
Proposed: []byte("# Storage\n\nOne paragraph, edited.\n"),
Threads: []service.Thread{{
Root: service.Comment{ID: 9, Author: "bigbes", Body: "orphaned critique"},
State: core.AnchorOutdated, Block: -1,
}},
})
if len(view.Unplaced) != 1 || view.Unplaced[0].Root.ID != 9 {
t.Fatalf("Unplaced = %+v, want the outdated thread", view.Unplaced)
}
if strings.Contains(string(view.HTML), "orphaned critique") {
t.Errorf("an outdated comment was rendered against a block; got:\n%s", view.HTML)
}
}
// TestThreadOnAnUnrenderedBlockIsHandedBack proves the invariant the renderer
// is built on: a thread comes out either on its block or in Unplaced, never
// neither. An old-side anchor that still resolves against the base but whose
// block the diff no longer renders — the agent restored the deleted paragraph —
// is the case that would otherwise vanish.
func TestThreadOnAnUnrenderedBlockIsHandedBack(t *testing.T) {
const src = "# Storage\n\nThe restored paragraph.\n"
anchor, err := service.AnchorOf("SPEC-0007", []byte(src), 1, core.SideOld)
if err != nil {
t.Fatalf("AnchorOf: %v", err)
}
view := renderDocDiff(docDiff{
DocID: "SPEC-0007", Path: "specs/a.md",
Base: []byte(src),
Proposed: []byte(src + "\nAnd a new one.\n"),
Threads: []service.Thread{{
Root: service.Comment{ID: 3, Body: "written when this was deleted"},
Anchor: anchor, State: core.AnchorExact, Block: 1,
}},
})
if len(view.Unplaced) != 1 {
t.Fatalf("Unplaced = %+v, want the thread whose block is not rendered", view.Unplaced)
}
if strings.Contains(string(view.HTML), "written when this was deleted") {
t.Errorf("the thread was rendered against some other block; got:\n%s", view.HTML)
}
}
// TestUnchangedDocumentKeepsItsThreads proves nothing is lost in the degenerate
// case: a document whose diff renders no blocks at all still hands its threads
// back rather than swallowing them.
func TestUnchangedDocumentKeepsItsThreads(t *testing.T) {
src := []byte("# Storage\n\nOne paragraph.\n")
view := renderDocDiff(docDiff{
DocID: "SPEC-0007", Path: "specs/a.md", Base: src, Proposed: src,
Threads: []service.Thread{{Root: service.Comment{ID: 1, Body: "still here"}, State: core.AnchorExact, Block: 1}},
})
if !view.Unchanged {
t.Fatalf("Unchanged = false for identical input")
}
if len(view.Unplaced) != 1 {
t.Errorf("Unplaced = %+v, want the thread of an unrendered document", view.Unplaced)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}