Files
sm-bm-plugin/classes/calendar_test.go
Jakub Zych 71b848a376 Adopt SummerCMS v0.1.1 admin features
Datepickers on native time.Time / lagoon.Date fields, file uploads on
materials, readings and class sessions managed from the course form
(course_id made nullable).
2026-10-03 06:03:34 +02:00

231 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}