package main
import (
"net/url"
"os"
"strings"
"testing"
"time"
"golang.org/x/net/html"
)
func TestInferAssignmentDate(t *testing.T) {
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
got := inferAssignmentDate("Jan 20", now)
want := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("got %s want %s", got, want)
}
}
func TestInferAssignmentDateRollsBackYear(t *testing.T) {
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
got := inferAssignmentDate("Dec 15", now)
want := time.Date(2025, 12, 15, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("got %s want %s", got, want)
}
}
func TestInferUpcomingDateRollsForwardYear(t *testing.T) {
now := time.Date(2026, 12, 28, 10, 0, 0, 0, time.UTC)
got := inferUpcomingDate("Jan 03", now)
want := time.Date(2027, 1, 3, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("got %s want %s", got, want)
}
}
func TestFilterUpcomingAssignments(t *testing.T) {
now := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC)
assignments := []Assignment{
{DueDate: time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC), Title: "Soon"},
{DueDate: time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC), Title: "Later"},
{DueDate: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC), Title: "Preparedness", Category: "Preparedness"},
{DueDate: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC), Title: "Weekly assessment", Category: "R.I.C.E. (weekly assessment)"},
}
got := filterUpcomingAssignments(assignments, now, 14)
if len(got) != 1 || got[0].Title != "Soon" {
t.Fatalf("unexpected filtered assignments: %#v", got)
}
}
func TestFilterMissingAssignmentsHidesRevisionForCurrentA(t *testing.T) {
start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
assignments := []Assignment{
{
DueDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
Course: "Geometry Lab",
Title: "Revise me",
Status: "Revision Needed",
},
{
DueDate: time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
Course: "Geometry Lab",
Title: "Still missing",
Status: "Missing",
},
}
got := filterMissingAssignments(assignments, start, map[string]struct{}{"geometry lab": {}})
if len(got) != 1 || got[0].Title != "Still missing" {
t.Fatalf("unexpected filtered assignments: %#v", got)
}
}
func TestDedupeAssignments(t *testing.T) {
assignment := Assignment{
DueDate: time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC),
Title: "Preparedness",
Link: "https://example.invalid/a",
}
got := dedupeAssignments([]Assignment{assignment, assignment})
if len(got) != 1 {
t.Fatalf("got %d items", len(got))
}
}
func TestExtractLikeLiveMissingTable(t *testing.T) {
doc := mustParseHTML(`
`)
table := findNode(doc, func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "table" && hasClass(n, "missing-assignments")
})
if table == nil {
t.Fatal("missing table not found")
}
row := findNode(table, func(n *html.Node) bool { return n.Type == html.ElementNode && n.Data == "tr" })
cells := directChildrenByTag(row, "td")
if got := nodeText(cells[0]); got != "Mar 25" {
t.Fatalf("got %q", got)
}
link := findNode(cells[1], func(n *html.Node) bool {
return n.Type == html.ElementNode && n.Data == "a" && attr(n, "data-alma-modal") != ""
})
if got := nodeText(link); got != "HW: Timeline Events" {
t.Fatalf("got %q", got)
}
}
func TestLoadAlmaCredsFromEnv(t *testing.T) {
t.Setenv("ALMA_USERNAME", "student")
t.Setenv("ALMA_PASSWORD", "secret")
got, err := loadAlmaCreds("")
if err != nil {
t.Fatalf("loadAlmaCreds returned error: %v", err)
}
if got["username"] != "student" || got["password"] != "secret" {
t.Fatalf("unexpected creds: %#v", got)
}
}
func TestLoadAlmaCredsRequiresPasswordWithEnvUsername(t *testing.T) {
t.Setenv("ALMA_USERNAME", "student")
t.Setenv("ALMA_PASSWORD", "")
if _, err := loadAlmaCreds(""); err == nil {
t.Fatal("expected error when ALMA_PASSWORD is missing")
}
}
func TestLoadAlmaCredsFromFile(t *testing.T) {
dir := t.TempDir()
path := dir + "/alma.creds"
if err := os.WriteFile(path, []byte("username: student\npassword: secret\n"), 0o600); err != nil {
t.Fatalf("write creds file: %v", err)
}
got, err := loadAlmaCreds(path)
if err != nil {
t.Fatalf("loadAlmaCreds returned error: %v", err)
}
if got["username"] != "student" || got["password"] != "secret" {
t.Fatalf("unexpected creds: %#v", got)
}
}
func TestNormalizeSMTPHeloName(t *testing.T) {
if got := normalizeSMTPHeloName("mail.example.invalid"); got != "mail.example.invalid" {
t.Fatalf("got %q", got)
}
if got := normalizeSMTPHeloName("mailer"); got != "mailer.localdomain" {
t.Fatalf("got %q", got)
}
}
func TestClassesWithCurrentA(t *testing.T) {
got := classesWithCurrentA([]ClassInfo{
{Name: "Geometry Lab", Grade: "A"},
{Name: "Civics", Grade: "B"},
{Name: "Intro. Spanish 1", Grade: "A-"},
})
if !hasCurrentA(got, "Geometry Lab") {
t.Fatal("expected Geometry Lab to have current A")
}
if !hasCurrentA(got, "Intro. Spanish 1") {
t.Fatal("expected Intro. Spanish 1 to have current A")
}
if hasCurrentA(got, "Civics") {
t.Fatal("did not expect Civics to have current A")
}
}
func TestFetchScheduleClasses(t *testing.T) {
doc := mustParseHTML(`
`)
base, err := url.Parse("https://example.invalid/children/student/schedule")
if err != nil {
t.Fatalf("parse base URL: %v", err)
}
got, err := extractScheduleClasses(doc, base)
if err != nil {
t.Fatalf("extractScheduleClasses returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d classes", len(got))
}
if got[1].Grade != "A" || got[1].Name != "Geometry Lab" {
t.Fatalf("unexpected classes: %#v", got)
}
}
func TestBuildBodyIncludesWeekday(t *testing.T) {
now := time.Date(2026, 3, 30, 10, 0, 0, 0, time.UTC)
start := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
body := buildBody([]Assignment{{
DueDate: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC),
Course: "Civics",
Title: "Essay",
Status: "Missing",
}}, nil, start, "https://example.invalid/assignments", "https://example.invalid/schedule", now, 14)
if !strings.Contains(body, "Monday 2026-03-30") {
t.Fatalf("body missing weekday date: %s", body)
}
}
func mustParseHTML(text string) *html.Node {
doc, err := html.Parse(strings.NewReader(text))
if err != nil {
panic(err)
}
return doc
}