package classes_test import ( "strings" "testing" "time" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/jakub/sm-bm-plugin/classes" "git.golem15.com/jakub/sm-bm-plugin/models" ) func sp(s string) *string { return &s } func up(n uint) *uint { return &n } func calendarFixture() classes.CalendarData { updated := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC) glob := &models.Course{ID: 1, Name: "Globalizacja i regionalizacja", Form: models.FormWyklad, Lecturer: sp("prof. dr hab. Piotr Mikiewicz")} hist := &models.Course{ID: 8, Name: "Historia wojen i konfliktów do 1945 roku", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} konc := &models.Course{ID: 9, Name: "Koncepcje wojny i pokoju – historia i współczesność", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} m3 := &models.Meeting{ID: 3, Number: 3, StartsOn: day("2026-10-24"), EndsOn: day("2026-10-25"), Notes: sp("stacjonarny (Koszarowa 3, bud. 21)"), UpdatedAt: updated} m2 := &models.Meeting{ID: 2, Number: 2, StartsOn: day("2026-10-17"), EndsOn: day("2026-10-18"), Notes: sp("online"), UpdatedAt: updated} return classes.CalendarData{ Sessions: []models.ClassSession{ {ID: 11, CourseID: up(1), Course: glob, MeetingID: up(3), Meeting: m3, StartsAt: ts("2026-10-24T08:00:00+02:00"), EndsAt: ts("2026-10-24T09:30:00+02:00"), Room: sp("237"), Notes: sp("prof. P. Mikiewicz"), UpdatedAt: updated}, {ID: 12, CourseID: up(8), Course: hist, MeetingID: up(3), Meeting: m3, StartsAt: ts("2026-10-25T08:00:00+01:00"), EndsAt: ts("2026-10-25T09:30:00+01:00"), Room: sp("110"), UpdatedAt: updated}, {ID: 13, CourseID: up(9), Course: konc, MeetingID: up(2), Meeting: m2, StartsAt: ts("2026-10-17T10:00:00+02:00"), EndsAt: ts("2026-10-17T11:30:00+02:00"), IsOnline: true, Room: sp("237"), UpdatedAt: updated}, }, Meetings: []models.Meeting{*m2, *m3}, Deadlines: []models.Deadline{ {ID: 21, Title: "Esej; wersja 1, szkic", DueAt: ts("2026-11-30T23:59:00+01:00"), CourseID: up(1), Course: glob, Description: sp("Wysłać\nmailem"), UpdatedAt: updated}, {ID: 22, Title: "Esej z koncepcji", DueAt: ts("2026-12-01T12:00:00+01:00"), CourseID: up(9), Course: konc, UpdatedAt: updated}, {ID: 23, Title: "Ślubowanie", DueAt: ts("2026-10-05T23:59:00+02:00"), UpdatedAt: updated}, }, } } // unfold returns the content lines of an ICS stream. func unfold(t *testing.T, ics []byte) []string { t.Helper() s := string(ics) if !strings.HasSuffix(s, "\r\n") { t.Fatalf("stream does not end with CRLF") } for _, line := range strings.Split(strings.TrimSuffix(s, "\r\n"), "\r\n") { if len(line) > 75 { t.Errorf("line of %d octets: %q", len(line), line) } } return strings.Split(strings.TrimSuffix(strings.ReplaceAll(s, "\r\n ", ""), "\r\n"), "\r\n") } // events splits the content lines into VEVENTs keyed by UID. func events(lines []string) map[string][]string { out := map[string][]string{} var cur []string for _, l := range lines { switch { case l == "BEGIN:VEVENT": cur = []string{} case l == "END:VEVENT": for _, c := range cur { if uid, ok := strings.CutPrefix(c, "UID:"); ok { out[uid] = cur } } cur = nil case cur != nil: cur = append(cur, l) } } return out } func has(lines []string, want string) bool { for _, l := range lines { if l == want { return true } } return false } func TestBuildCalendarHeaderAndBalance(t *testing.T) { lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true, IncludeDeadlines: true, SiteURL: "https://bm.jakubzych.com"})) if lines[0] != "BEGIN:VCALENDAR" || lines[len(lines)-1] != "END:VCALENDAR" { t.Errorf("first/last lines %q %q", lines[0], lines[len(lines)-1]) } for _, want := range []string{ "PRODID:-//jakubzych//BM UWr//PL", "VERSION:2.0", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "X-WR-CALNAME:BM UWr – plan zajęć", "X-WR-TIMEZONE:Europe/Warsaw", "REFRESH-INTERVAL;VALUE=DURATION:PT6H", "X-PUBLISHED-TTL:PT6H", } { if !has(lines, want) { t.Errorf("missing %q", want) } } depth := map[string]int{} for _, l := range lines { if c, ok := strings.CutPrefix(l, "BEGIN:"); ok { depth[c]++ } if c, ok := strings.CutPrefix(l, "END:"); ok { depth[c]-- } } for c, d := range depth { if d != 0 { t.Errorf("BEGIN/END of %s off by %d", c, d) } } if n := len(events(lines)); n != 3+2+3 { t.Errorf("%d events, want 8", n) } } func TestBuildCalendarSessions(t *testing.T) { ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{SiteURL: "https://bm.jakubzych.com"}))) glob := ev["session-11@bm.jakubzych.com"] for _, want := range []string{ "DTSTART:20261024T060000Z", "DTEND:20261024T073000Z", "DTSTAMP:20261002T123000Z", "LAST-MODIFIED:20261002T123000Z", "SEQUENCE:0", "SUMMARY:Globalizacja i regionalizacja – wykład", `LOCATION:Koszarowa 3\, s. 237 (bud. 21 ISMiB)`, `DESCRIPTION:Prowadzący: prof. dr hab. Piotr Mikiewicz\nZjazd 3\nprof. P. Mikiewicz\nhttps://bm.jakubzych.com/przedmioty/1`, } { if !has(glob, want) { t.Errorf("session 11 lacks %q:\n%s", want, strings.Join(glob, "\n")) } } // After the DST change Warsaw is UTC+1. hist := ev["session-12@bm.jakubzych.com"] if !has(hist, "DTSTART:20261025T070000Z") || !has(hist, "SUMMARY:Historia wojen i konfliktów do 1945 roku") { t.Errorf("session 12:\n%s", strings.Join(hist, "\n")) } if !has(hist, `DESCRIPTION:Zjazd 3\nPrzedmiot do wyboru\nhttps://bm.jakubzych.com/przedmioty/8`) { t.Errorf("session 12 description:\n%s", strings.Join(hist, "\n")) } online := ev["session-13@bm.jakubzych.com"] if !has(online, "SUMMARY:Koncepcje wojny i pokoju – historia i współczesność (online)") || !has(online, "LOCATION:online") { t.Errorf("online session:\n%s", strings.Join(online, "\n")) } if len(ev) != 3 { t.Errorf("meetings or deadlines without being asked: %v", ev) } } func TestBuildCalendarExclusions(t *testing.T) { ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{ExcludedCourseIDs: []uint{9}, IncludeDeadlines: true}))) if _, ok := ev["session-13@bm.jakubzych.com"]; ok { t.Error("a session of an excluded course is listed") } if _, ok := ev["deadline-22@bm.jakubzych.com"]; ok { t.Error("a deadline of an excluded course is listed") } for _, uid := range []string{"session-11@bm.jakubzych.com", "session-12@bm.jakubzych.com", "deadline-21@bm.jakubzych.com", "deadline-23@bm.jakubzych.com"} { if _, ok := ev[uid]; !ok { t.Errorf("%s is missing", uid) } } // Without a site URL there is no link. if has(ev["session-11@bm.jakubzych.com"], "URL:https://bm.jakubzych.com/przedmioty/1") { t.Error("link without a site URL") } } func TestBuildCalendarMeetingsAreAllDay(t *testing.T) { ev := events(unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeMeetings: true}))) m3 := ev["meeting-3@bm.jakubzych.com"] for _, want := range []string{ "DTSTART;VALUE=DATE:20261024", "DTEND;VALUE=DATE:20261026", "SUMMARY:3. zjazd (stacjonarny)", "TRANSP:TRANSPARENT", `DESCRIPTION:stacjonarny (Koszarowa 3\, bud. 21)`, } { if !has(m3, want) { t.Errorf("meeting 3 lacks %q:\n%s", want, strings.Join(m3, "\n")) } } if !has(ev["meeting-2@bm.jakubzych.com"], "SUMMARY:2. zjazd (online)") { t.Errorf("meeting 2: %v", ev["meeting-2@bm.jakubzych.com"]) } } func TestBuildCalendarDeadlines(t *testing.T) { lines := unfold(t, classes.BuildCalendar(calendarFixture(), classes.CalendarOptions{IncludeDeadlines: true})) d := events(lines)["deadline-21@bm.jakubzych.com"] for _, want := range []string{ "DTSTART:20261130T222900Z", "DTEND:20261130T225900Z", `SUMMARY:Termin: Esej\; wersja 1\, szkic`, `DESCRIPTION:Przedmiot: Globalizacja i regionalizacja\nWysłać\nmailem`, "BEGIN:VALARM", "ACTION:DISPLAY", "TRIGGER:-P1D", "END:VALARM", } { if !has(d, want) { t.Errorf("deadline lacks %q:\n%s", want, strings.Join(d, "\n")) } } if strings.Contains(strings.Join(lines, "\n"), "VALARM") && has(events(lines)["session-11@bm.jakubzych.com"], "BEGIN:VALARM") { t.Error("a session has an alarm") } } func TestSummaries(t *testing.T) { for form, want := range map[string]string{ models.FormWyklad: "X – wykład", models.FormCwiczenia: "X – ćwiczenia", models.FormKonwersatorium: "X – konwersatorium", models.FormSeminarium: "X – seminarium", models.FormInne: "X", } { if got := classes.SessionSummary(models.ClassSession{Course: &models.Course{Name: "X", Form: form}}); got != want { t.Errorf("%s: %q, want %q", form, got, want) } } if got := classes.MeetingSummary(models.Meeting{Number: 7}); got != "7. zjazd" { t.Errorf("meeting without notes = %q", got) } } func day(s string) lagoon.Date { d, err := lagoon.ParseDate(s) if err != nil { panic(err) } return d } func ts(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t }