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(`
Mar
25
US History HW: Timeline Events
`) 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(`
Geometry Lab S1, S2 - Teacher A Aug 25, 2025 -
Civics S2 - Teacher B Jan 20, 2026 -
`) 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 }