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 <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)
}
}