Skip to content
quarry Artifact [c82e640839]
Login

Artifact [c82e640839]

Artifact c82e6408391e663970d8066e3c7f4802c12fd1ad58491ccf9c83fb74f9612378:


package semdiff

import (
	"strings"
	"testing"
)

// --- JSON canonicalization ---

func TestCanonJSONSortsKeys(t *testing.T) {
	a, err := CanonJSON([]byte(`{"b":1,"a":2}`))
	if err != nil {
		t.Fatal(err)
	}
	b, err := CanonJSON([]byte(`{"a":2,"b":1}`))
	if err != nil {
		t.Fatal(err)
	}
	if string(a) != string(b) {
		t.Fatalf("key order changed canon form:\n%s\n%s", a, b)
	}
}

func TestCanonJSONNormalizesTimestamps(t *testing.T) {
	// Same instant, different representations.
	a, err := CanonJSON([]byte(`{"t":"2026-08-14T10:00:00+02:00"}`))
	if err != nil {
		t.Fatal(err)
	}
	b, err := CanonJSON([]byte(`{"t":"2026-08-14 08:00:00Z"}`))
	if err != nil {
		t.Fatal(err)
	}
	if string(a) != string(b) {
		t.Fatalf("timestamps not unified:\n%s\n%s", a, b)
	}
	if !strings.Contains(string(a), "2026-08-14T08:00:00Z") {
		t.Fatalf("want UTC RFC3339, got %s", a)
	}
}

func TestCanonJSONLeavesNonTimestampsAlone(t *testing.T) {
	out, err := CanonJSON([]byte(`{"v":"1.2.3","n":42,"s":"2026 was a year"}`))
	if err != nil {
		t.Fatal(err)
	}
	for _, want := range []string{`"1.2.3"`, `42`, `"2026 was a year"`} {
		if !strings.Contains(string(out), want) {
			t.Fatalf("mangled %s in %s", want, out)
		}
	}
}

func TestCanonJSONRejectsInvalid(t *testing.T) {
	if _, err := CanonJSON([]byte(`{nope`)); err == nil {
		t.Fatal("want parse error, got nil")
	}
}

// --- HTML canonicalization ---

func TestCanonHTMLAttributeOrder(t *testing.T) {
	a, err := CanonHTML([]byte(`<div id="x" class="y">hi</div>`))
	if err != nil {
		t.Fatal(err)
	}
	b, err := CanonHTML([]byte(`<div class="y" id="x">hi</div>`))
	if err != nil {
		t.Fatal(err)
	}
	if string(a) != string(b) {
		t.Fatalf("attr order changed canon form:\n%s\n%s", a, b)
	}
}

func TestCanonHTMLWhitespace(t *testing.T) {
	a, err := CanonHTML([]byte("<p>hello   world</p>"))
	if err != nil {
		t.Fatal(err)
	}
	b, err := CanonHTML([]byte("<p>\n  hello world\n</p>"))
	if err != nil {
		t.Fatal(err)
	}
	if string(a) != string(b) {
		t.Fatalf("whitespace changed canon form:\n%q\n%q", a, b)
	}
}

func TestCanonHTMLPreservesPre(t *testing.T) {
	a, err := CanonHTML([]byte("<pre>a  b\nc</pre>"))
	if err != nil {
		t.Fatal(err)
	}
	b, err := CanonHTML([]byte("<pre>a b c</pre>"))
	if err != nil {
		t.Fatal(err)
	}
	if string(a) == string(b) {
		t.Fatal("pre content collapsed — code blocks must stay byte-exact")
	}
}

func TestCanonHTMLContentDifferenceSurvives(t *testing.T) {
	a, _ := CanonHTML([]byte(`<p>alpha</p>`))
	b, _ := CanonHTML([]byte(`<p>beta</p>`))
	if string(a) == string(b) {
		t.Fatal("distinct content canonicalized to same form")
	}
}

// --- Diff ---

func TestDiffEqualAfterCanon(t *testing.T) {
	d, err := Diff(JSON, []byte(`{"b":1,"a":2}`), []byte(`{"a":2,"b":1}`))
	if err != nil {
		t.Fatal(err)
	}
	if d != "" {
		t.Fatalf("want no diff, got:\n%s", d)
	}
}

func TestDiffReportsFirstDivergence(t *testing.T) {
	d, err := Diff(JSON, []byte(`{"a":1}`), []byte(`{"a":2}`))
	if err != nil {
		t.Fatal(err)
	}
	if d == "" || !strings.Contains(d, "1") || !strings.Contains(d, "2") {
		t.Fatalf("diff should show both sides, got:\n%s", d)
	}
}

func TestDiffHTMLKind(t *testing.T) {
	d, err := Diff(HTML, []byte(`<div class="y" id="x">a</div>`), []byte(`<div id="x" class="y">a</div>`))
	if err != nil {
		t.Fatal(err)
	}
	if d != "" {
		t.Fatalf("want no diff, got:\n%s", d)
	}
}

// Render timings differ between two runs of the same request by
// definition. Without an escape hatch the disposability harness reads
// "<1ms" vs "4ms" as derived state that failed to converge — a true
// statement about the clock and a false one about the index.
func TestVolatileContentIsIgnored(t *testing.T) {
	a := []byte(`<footer>quarry 1.0 · <span data-volatile>rendered in &lt;1ms</span></footer>`)
	b := []byte(`<footer>quarry 1.0 · <span data-volatile>rendered in 412ms</span></footer>`)
	d, err := Diff(HTML, a, b)
	if err != nil {
		t.Fatal(err)
	}
	if d != "" {
		t.Errorf("volatile content compared: %s", d)
	}

	// It must not become a blanket mute: a real difference OUTSIDE the
	// marked element still has to fail.
	c := []byte(`<footer>quarry 1.1 · <span data-volatile>rendered in 9ms</span></footer>`)
	if d, err := Diff(HTML, a, c); err != nil {
		t.Fatal(err)
	} else if d == "" {
		t.Error("a real difference next to volatile content was ignored")
	}
}

// A void element carries its payload in an attribute, so emptying its
// children normalizes nothing. og:url and og:image embed the host the
// reader arrived on — genuinely volatile across two harness servers on
// different ephemeral ports, and genuinely nothing to do with the state
// the disposability test is comparing.
func TestVolatileCanNameAttributes(t *testing.T) {
	a := []byte(`<meta property="og:url" content="http://127.0.0.1:5001/" data-volatile="content">`)
	b := []byte(`<meta property="og:url" content="http://127.0.0.1:9999/" data-volatile="content">`)
	d, err := Diff(HTML, a, b)
	if err != nil {
		t.Fatal(err)
	}
	if d != "" {
		t.Errorf("named attribute was not treated as volatile:\n%s", d)
	}

	// Only the NAMED attribute is blanked: if data-volatile swallowed the
	// whole element, a real change of identity would stop failing too.
	c := []byte(`<meta property="og:title" content="http://127.0.0.1:5001/" data-volatile="content">`)
	if d, err := Diff(HTML, a, c); err != nil {
		t.Fatal(err)
	} else if d == "" {
		t.Error("a differing unnamed attribute was silently normalized away")
	}

	// The bare form must keep emptying children, or the render-time span
	// it was written for starts failing the build again.
	e := []byte(`<span data-volatile>rendered in 1ms</span>`)
	f := []byte(`<span data-volatile>rendered in 4ms</span>`)
	if d, err := Diff(HTML, e, f); err != nil {
		t.Fatal(err)
	} else if d != "" {
		t.Errorf("bare data-volatile regressed:\n%s", d)
	}
}