Skip to content

Commit e1d5971

Browse files
sumnerevansclaude
andcommitted
templates: add equivalence tests comparing templ vs html/template output
Add testhelpers.CompareHTML which parses HTML fragments into DOM trees and diffs them structurally, ignoring whitespace-only text nodes, HTML comments, spurious attributes from HTML syntax errors (e.g. trailing commas), and normalises CSS class attribute whitespace. Add equivalence tests for: - Home page (registration enabled/disabled) - Register page (registration enabled/disabled) - Archive page (minimal fixture with 1 year, 2 divisions, 2 teams each) - Navbar partial (no active page, active home page, logged-in user) - Teacher login page (no error, email-not-found, email-not-confirmed) Each test renders the same data with both the old html/template path (executing the "content" or "navbar" named template) and the new templ component, then asserts the resulting DOM trees are equivalent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 375c503 commit e1d5971

6 files changed

Lines changed: 660 additions & 0 deletions

File tree

internal/templates/archive_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package templates_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"html/template"
7+
"strings"
8+
"testing"
9+
10+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates"
11+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates/testhelpers"
12+
"github.com/ColoradoSchoolOfMines/mineshspc.com/website"
13+
)
14+
15+
// minimalArchiveFixture is a small fixture for testing: 1 year, 2 result categories, 2 teams each.
16+
var minimalArchiveFixture = []templates.YearInfo{
17+
{
18+
Year: 2023,
19+
RecapParagraphs: []string{
20+
"This is the first recap paragraph.",
21+
"This is the second recap paragraph.",
22+
},
23+
Links: []templates.Link{
24+
{URL: "/static/solutions.pdf", Title: "Solution Slides"},
25+
{URL: "https://kattis.com/problems", Title: "Problems"},
26+
},
27+
Results: []templates.CompetitionResult{
28+
{
29+
Name: "Advanced Division",
30+
Shortname: "Advanced",
31+
Teams: []templates.WinningTeam{
32+
{Place: "1st", Name: "Team Alpha", School: "Alpha High School", Location: "Denver, CO"},
33+
{Place: "2nd", Name: "Team Beta", School: "Beta Academy", Location: "Boulder, CO"},
34+
},
35+
},
36+
{
37+
Name: "Beginner Division",
38+
Shortname: "Beginner",
39+
Teams: []templates.WinningTeam{
40+
{Place: "1st", Name: "Team Gamma", School: "Gamma School", Location: "Fort Collins, CO"},
41+
{Place: "2nd", Name: "Team Delta", School: "Delta Institute", Location: "Aurora, CO"},
42+
},
43+
},
44+
},
45+
},
46+
}
47+
48+
// htmlArchiveFixture mirrors minimalArchiveFixture but uses plain string URLs for the HTML template.
49+
var htmlArchiveFixture = map[string]any{
50+
"YearInfo": []map[string]any{
51+
{
52+
"Year": 2023,
53+
"RecapParagraphs": []string{
54+
"This is the first recap paragraph.",
55+
"This is the second recap paragraph.",
56+
},
57+
"Links": []map[string]any{
58+
{"URL": "/static/solutions.pdf", "Title": "Solution Slides"},
59+
{"URL": "https://kattis.com/problems", "Title": "Problems"},
60+
},
61+
"Results": []map[string]any{
62+
{
63+
"Name": "Advanced Division",
64+
"Shortname": "Advanced",
65+
"Teams": []map[string]any{
66+
{"Place": "1st", "Name": "Team Alpha", "School": "Alpha High School", "Location": "Denver, CO"},
67+
{"Place": "2nd", "Name": "Team Beta", "School": "Beta Academy", "Location": "Boulder, CO"},
68+
},
69+
},
70+
{
71+
"Name": "Beginner Division",
72+
"Shortname": "Beginner",
73+
"Teams": []map[string]any{
74+
{"Place": "1st", "Name": "Team Gamma", "School": "Gamma School", "Location": "Fort Collins, CO"},
75+
{"Place": "2nd", "Name": "Team Delta", "School": "Delta Institute", "Location": "Aurora, CO"},
76+
},
77+
},
78+
},
79+
},
80+
},
81+
}
82+
83+
func renderOldArchive(t *testing.T) string {
84+
t.Helper()
85+
tmpl, err := template.ParseFS(website.TemplateFS, "templates/base.html", "templates/partials/*", "templates/archive.html")
86+
if err != nil {
87+
t.Fatalf("failed to parse templates: %v", err)
88+
}
89+
data := map[string]any{
90+
"PageName": "archive",
91+
"Data": htmlArchiveFixture,
92+
"HostedByHTML": template.HTML("CS@Mines"),
93+
"RegistrationEnabled": false,
94+
}
95+
var buf bytes.Buffer
96+
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
97+
t.Fatalf("failed to execute template: %v", err)
98+
}
99+
return buf.String()
100+
}
101+
102+
func renderNewArchive(t *testing.T) string {
103+
t.Helper()
104+
ctx := context.Background()
105+
var buf bytes.Buffer
106+
if err := templates.Archive(minimalArchiveFixture).Render(ctx, &buf); err != nil {
107+
t.Fatalf("failed to render templ: %v", err)
108+
}
109+
return buf.String()
110+
}
111+
112+
func TestArchiveEquivalence(t *testing.T) {
113+
oldHTML := renderOldArchive(t)
114+
newHTML := renderNewArchive(t)
115+
116+
// Sanity checks
117+
if !strings.Contains(oldHTML, "2023") {
118+
t.Errorf("old HTML should contain year 2023")
119+
}
120+
if !strings.Contains(newHTML, "2023") {
121+
t.Errorf("new HTML should contain year 2023")
122+
}
123+
if !strings.Contains(oldHTML, "Team Alpha") {
124+
t.Errorf("old HTML should contain Team Alpha")
125+
}
126+
if !strings.Contains(newHTML, "Team Alpha") {
127+
t.Errorf("new HTML should contain Team Alpha")
128+
}
129+
if !strings.Contains(oldHTML, "Advanced Division Competition Winners") {
130+
t.Errorf("old HTML should contain accordion header, got:\n%s", oldHTML)
131+
}
132+
if !strings.Contains(newHTML, "Advanced Division Competition Winners") {
133+
t.Errorf("new HTML should contain accordion header")
134+
}
135+
136+
testhelpers.CompareHTML(t, oldHTML, newHTML)
137+
}

internal/templates/home_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package templates_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"html/template"
7+
"io/fs"
8+
"strings"
9+
"testing"
10+
11+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/contextkeys"
12+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates"
13+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates/testhelpers"
14+
"github.com/ColoradoSchoolOfMines/mineshspc.com/website"
15+
)
16+
17+
func renderOldHome(t *testing.T, registrationEnabled bool) string {
18+
t.Helper()
19+
tmpl, err := template.ParseFS(website.TemplateFS, "templates/base.html", "templates/partials/*", "templates/home.html")
20+
if err != nil {
21+
t.Fatalf("failed to parse templates: %v", err)
22+
}
23+
data := map[string]any{
24+
"PageName": "home",
25+
"Data": map[string]any{},
26+
"HostedByHTML": template.HTML("CS@Mines"),
27+
"RegistrationEnabled": registrationEnabled,
28+
}
29+
var buf bytes.Buffer
30+
if err := tmpl.ExecuteTemplate(&buf, "content", data); err != nil {
31+
t.Fatalf("failed to execute template: %v", err)
32+
}
33+
return buf.String()
34+
}
35+
36+
func renderNewHome(t *testing.T, registrationEnabled bool) string {
37+
t.Helper()
38+
ctx := context.WithValue(context.Background(), contextkeys.ContextKeyRegistrationEnabled, registrationEnabled)
39+
var buf bytes.Buffer
40+
if err := templates.Home().Render(ctx, &buf); err != nil {
41+
t.Fatalf("failed to render templ: %v", err)
42+
}
43+
return buf.String()
44+
}
45+
46+
// Ensure website.TemplateFS has the expected file
47+
func TestWebsiteTemplateFS(t *testing.T) {
48+
entries, err := fs.ReadDir(website.TemplateFS, "templates")
49+
if err != nil {
50+
t.Fatalf("failed to read template dir: %v", err)
51+
}
52+
found := false
53+
for _, e := range entries {
54+
if e.Name() == "home.html" {
55+
found = true
56+
break
57+
}
58+
}
59+
if !found {
60+
t.Fatal("home.html not found in website.TemplateFS")
61+
}
62+
}
63+
64+
func TestHomeEquivalence_RegistrationDisabled(t *testing.T) {
65+
oldHTML := renderOldHome(t, false)
66+
newHTML := renderNewHome(t, false)
67+
68+
// Sanity checks
69+
if !strings.Contains(oldHTML, "not yet open") {
70+
t.Errorf("old HTML should contain 'not yet open', got:\n%s", oldHTML)
71+
}
72+
if !strings.Contains(newHTML, "not yet open") {
73+
t.Errorf("new HTML should contain 'not yet open', got:\n%s", newHTML)
74+
}
75+
76+
testhelpers.CompareHTML(t, oldHTML, newHTML)
77+
}
78+
79+
func TestHomeEquivalence_RegistrationEnabled(t *testing.T) {
80+
oldHTML := renderOldHome(t, true)
81+
newHTML := renderNewHome(t, true)
82+
83+
// Sanity checks
84+
if !strings.Contains(oldHTML, "Register Now") {
85+
t.Errorf("old HTML should contain 'Register Now', got:\n%s", oldHTML)
86+
}
87+
if !strings.Contains(newHTML, "Register Now") {
88+
t.Errorf("new HTML should contain 'Register Now', got:\n%s", newHTML)
89+
}
90+
91+
testhelpers.CompareHTML(t, oldHTML, newHTML)
92+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package partials_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"html/template"
7+
"strings"
8+
"testing"
9+
10+
"github.com/ColoradoSchoolOfMines/mineshspc.com/database"
11+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/contextkeys"
12+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates/partials"
13+
"github.com/ColoradoSchoolOfMines/mineshspc.com/internal/templates/testhelpers"
14+
"github.com/ColoradoSchoolOfMines/mineshspc.com/website"
15+
)
16+
17+
func renderOldNavbar(t *testing.T, pageName string, registrationEnabled bool, username string) string {
18+
t.Helper()
19+
tmpl, err := template.ParseFS(website.TemplateFS, "templates/base.html", "templates/partials/*")
20+
if err != nil {
21+
t.Fatalf("failed to parse templates: %v", err)
22+
}
23+
data := map[string]any{
24+
"PageName": pageName,
25+
"Data": map[string]any{"Username": username},
26+
"HostedByHTML": template.HTML("CS@Mines"),
27+
"RegistrationEnabled": registrationEnabled,
28+
}
29+
var buf bytes.Buffer
30+
if err := tmpl.ExecuteTemplate(&buf, "navbar", data); err != nil {
31+
t.Fatalf("failed to execute navbar template: %v", err)
32+
}
33+
return buf.String()
34+
}
35+
36+
func renderNewNavbar(t *testing.T, pageName partials.PageName, registrationEnabled bool, username string) string {
37+
t.Helper()
38+
ctx := context.Background()
39+
ctx = context.WithValue(ctx, contextkeys.ContextKeyPageName, pageName)
40+
ctx = context.WithValue(ctx, contextkeys.ContextKeyRegistrationEnabled, registrationEnabled)
41+
if username != "" {
42+
ctx = context.WithValue(ctx, contextkeys.ContextKeyLoggedInTeacher, &database.Teacher{Name: username})
43+
}
44+
var buf bytes.Buffer
45+
if err := partials.Navbar().Render(ctx, &buf); err != nil {
46+
t.Fatalf("failed to render navbar: %v", err)
47+
}
48+
return buf.String()
49+
}
50+
51+
func TestNavbarEquivalence_NoActivePage_RegistrationDisabled_NotLoggedIn(t *testing.T) {
52+
oldHTML := renderOldNavbar(t, "", false, "")
53+
newHTML := renderNewNavbar(t, "", false, "")
54+
55+
// Should not contain register link
56+
if strings.Contains(oldHTML, "id=\"registration-link\"") {
57+
t.Errorf("old HTML should not have registration link when disabled")
58+
}
59+
if strings.Contains(newHTML, "id=\"registration-link\"") {
60+
t.Errorf("new HTML should not have registration link when disabled")
61+
}
62+
// Should contain teacher login
63+
if !strings.Contains(oldHTML, "Teacher Login") {
64+
t.Errorf("old HTML should contain Teacher Login")
65+
}
66+
if !strings.Contains(newHTML, "Teacher Login") {
67+
t.Errorf("new HTML should contain Teacher Login")
68+
}
69+
70+
testhelpers.CompareHTML(t, oldHTML, newHTML)
71+
}
72+
73+
func TestNavbarEquivalence_HomePage_RegistrationEnabled(t *testing.T) {
74+
oldHTML := renderOldNavbar(t, "home", true, "")
75+
newHTML := renderNewNavbar(t, partials.PageNameHome, true, "")
76+
77+
// Should contain register link
78+
if !strings.Contains(oldHTML, "id=\"registration-link\"") {
79+
t.Errorf("old HTML should have registration link when enabled, got:\n%s", oldHTML)
80+
}
81+
if !strings.Contains(newHTML, "id=\"registration-link\"") {
82+
t.Errorf("new HTML should have registration link when enabled")
83+
}
84+
// Home link should be active
85+
if !strings.Contains(oldHTML, "home-link-active") {
86+
t.Errorf("old HTML should have home-link-active class")
87+
}
88+
if !strings.Contains(newHTML, "home-link-active") {
89+
t.Errorf("new HTML should have home-link-active class")
90+
}
91+
92+
testhelpers.CompareHTML(t, oldHTML, newHTML)
93+
}
94+
95+
func TestNavbarEquivalence_LoggedIn(t *testing.T) {
96+
oldHTML := renderOldNavbar(t, "home", true, "Jane Smith")
97+
newHTML := renderNewNavbar(t, partials.PageNameHome, true, "Jane Smith")
98+
99+
if !strings.Contains(oldHTML, "Jane Smith") {
100+
t.Errorf("old HTML should contain username")
101+
}
102+
if !strings.Contains(newHTML, "Jane Smith") {
103+
t.Errorf("new HTML should contain username")
104+
}
105+
if !strings.Contains(oldHTML, "Logout") {
106+
t.Errorf("old HTML should contain Logout link")
107+
}
108+
if !strings.Contains(newHTML, "Logout") {
109+
t.Errorf("new HTML should contain Logout link")
110+
}
111+
112+
testhelpers.CompareHTML(t, oldHTML, newHTML)
113+
}

0 commit comments

Comments
 (0)