From 2bf0e72e2eed4924ff752b6801fce6e604c52222 Mon Sep 17 00:00:00 2001 From: Xevion Date: Mon, 25 Aug 2025 23:30:46 -0500 Subject: [PATCH] fix: improve term year logic due to fall-spring literal year change issues, add testing --- internal/utils/term.go | 38 +++++-- tests/term_test.go | 221 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 tests/term_test.go diff --git a/internal/utils/term.go b/internal/utils/term.go index d1d04ce..1386ece 100644 --- a/internal/utils/term.go +++ b/internal/utils/term.go @@ -68,33 +68,49 @@ func GetYearDayRange(year uint16) (YearDayRange, YearDayRange, YearDayRange) { // GetCurrentTerm returns the current term, and the next term. Only the first term is nillable. // YearDay ranges are inclusive of the start, and exclusive of the end. +// You can think of the 'year' part of it as the 'school year', the second part of the 20XX-(20XX+1) phrasing. +// +// e.g. the Fall 2025, Spring 2026, and Summer 2026 terms all occur as part of the 2025-2026 school year. The second year, 2026, is the part used in all term identifiers. +// So even though the Fall 2025 term occurs in 2025, it uses the 2026 year in it's term identifier. +// +// Fall of 2024 => 202510 +// Spring of 2025 => 202520 +// Summer of 2025 => 202530 +// Fall of 2025 => 202610 +// Spring of 2026 => 202620 +// Summer of 2026 => 202630 +// +// Reading out 'Fall of 2024' as '202510' might be confusing, but it's correct. func GetCurrentTerm(now time.Time) (*Term, *Term) { - year := uint16(now.Year()) + literalYear := uint16(now.Year()) dayOfYear := uint16(now.YearDay()) - // Fall of 2024 => 202410 - // Spring of 2024 => 202420 - // Fall of 2025 => 202510 - // Summer of 2025 => 202530 + // If we're past the end of the summer term, we're 'in' the next school year. + var termYear uint16 + if dayOfYear > SummerRange.End { + termYear = literalYear + 1 + } else { + termYear = literalYear + } if (dayOfYear < SpringRange.Start) || (dayOfYear >= FallRange.End) { // Fall over, Spring not yet begun - return nil, &Term{Year: year + 1, Season: Spring} + return nil, &Term{Year: termYear, Season: Spring} } else if (dayOfYear >= SpringRange.Start) && (dayOfYear < SpringRange.End) { // Spring - return &Term{Year: year, Season: Spring}, &Term{Year: year, Season: Summer} + return &Term{Year: termYear, Season: Spring}, &Term{Year: termYear, Season: Summer} } else if dayOfYear < SummerRange.Start { // Spring over, Summer not yet begun - return nil, &Term{Year: year, Season: Summer} + return nil, &Term{Year: termYear, Season: Summer} } else if (dayOfYear >= SummerRange.Start) && (dayOfYear < SummerRange.End) { // Summer - return &Term{Year: year, Season: Summer}, &Term{Year: year, Season: Fall} + return &Term{Year: termYear, Season: Summer}, &Term{Year: termYear, Season: Fall} } else if dayOfYear < FallRange.Start { // Summer over, Fall not yet begun - return nil, &Term{Year: year + 1, Season: Fall} + return nil, &Term{Year: termYear, Season: Fall} } else if (dayOfYear >= FallRange.Start) && (dayOfYear < FallRange.End) { // Fall - return &Term{Year: year + 1, Season: Fall}, nil + return &Term{Year: termYear, Season: Fall}, nil } panic(fmt.Sprintf("Impossible Code Reached (dayOfYear: %d)", dayOfYear)) diff --git a/tests/term_test.go b/tests/term_test.go new file mode 100644 index 0000000..70e7368 --- /dev/null +++ b/tests/term_test.go @@ -0,0 +1,221 @@ +package utils_test + +import ( + "banner/internal/config" + "banner/internal/utils" + "testing" + "time" +) + +func TestGetCurrentTerm(t *testing.T) { + // Initialize config for testing + config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago") + + // Use current year to avoid issues with global state + currentYear := uint16(time.Now().Year()) + + tests := []struct { + name string + date time.Time + expectedCurrent *utils.Term + expectedNext *utils.Term + }{ + { + name: "Spring term", + date: time.Date(int(currentYear), 3, 15, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Spring}, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer}, + }, + { + name: "Summer term", + date: time.Date(int(currentYear), 6, 15, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Summer}, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Fall}, + }, + { + name: "Fall term", + date: time.Date(int(currentYear), 9, 15, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear + 1, Season: utils.Fall}, + expectedNext: nil, + }, + { + name: "Between Spring and Summer", + date: time.Date(int(currentYear), 5, 20, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: nil, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer}, + }, + { + name: "Between Summer and Fall", + date: time.Date(int(currentYear), 8, 16, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: nil, + expectedNext: &utils.Term{Year: currentYear + 1, Season: utils.Fall}, + }, + { + name: "Between Fall and Spring", + date: time.Date(int(currentYear), 12, 15, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: nil, + expectedNext: &utils.Term{Year: currentYear + 1, Season: utils.Spring}, + }, + { + name: "Early January before Spring", + date: time.Date(int(currentYear), 1, 10, 12, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: nil, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Spring}, + }, + { + name: "Spring start date", + date: time.Date(int(currentYear), 1, 14, 0, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Spring}, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Summer}, + }, + { + name: "Summer start date", + date: time.Date(int(currentYear), 5, 25, 0, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear, Season: utils.Summer}, + expectedNext: &utils.Term{Year: currentYear, Season: utils.Fall}, + }, + { + name: "Fall start date", + date: time.Date(int(currentYear), 8, 18, 0, 0, 0, 0, config.CentralTimeLocation), + expectedCurrent: &utils.Term{Year: currentYear + 1, Season: utils.Fall}, + expectedNext: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current, next := utils.GetCurrentTerm(tt.date) + + if !termsEqual(current, tt.expectedCurrent) { + t.Errorf("GetCurrentTerm() current = %v, want %v", current, tt.expectedCurrent) + } + + if !termsEqual(next, tt.expectedNext) { + t.Errorf("GetCurrentTerm() next = %v, want %v", next, tt.expectedNext) + } + }) + } +} + +func TestGetYearDayRange(t *testing.T) { + config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago") + + spring, summer, fall := utils.GetYearDayRange(2024) + + // Verify Spring range (Jan 14 to May 1) + expectedSpringStart := time.Date(2024, 1, 14, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + expectedSpringEnd := time.Date(2024, 5, 1, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + + if spring.Start != uint16(expectedSpringStart) { + t.Errorf("Spring start = %d, want %d", spring.Start, expectedSpringStart) + } + if spring.End != uint16(expectedSpringEnd) { + t.Errorf("Spring end = %d, want %d", spring.End, expectedSpringEnd) + } + + // Verify Summer range (May 25 to Aug 15) + expectedSummerStart := time.Date(2024, 5, 25, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + expectedSummerEnd := time.Date(2024, 8, 15, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + + if summer.Start != uint16(expectedSummerStart) { + t.Errorf("Summer start = %d, want %d", summer.Start, expectedSummerStart) + } + if summer.End != uint16(expectedSummerEnd) { + t.Errorf("Summer end = %d, want %d", summer.End, expectedSummerEnd) + } + + // Verify Fall range (Aug 18 to Dec 10) + expectedFallStart := time.Date(2024, 8, 18, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + expectedFallEnd := time.Date(2024, 12, 10, 0, 0, 0, 0, config.CentralTimeLocation).YearDay() + + if fall.Start != uint16(expectedFallStart) { + t.Errorf("Fall start = %d, want %d", fall.Start, expectedFallStart) + } + if fall.End != uint16(expectedFallEnd) { + t.Errorf("Fall end = %d, want %d", fall.End, expectedFallEnd) + } +} + +func TestParseTerm(t *testing.T) { + tests := []struct { + code string + expected utils.Term + }{ + {"202410", utils.Term{Year: 2024, Season: utils.Fall}}, + {"202420", utils.Term{Year: 2024, Season: utils.Spring}}, + {"202430", utils.Term{Year: 2024, Season: utils.Summer}}, + {"202510", utils.Term{Year: 2025, Season: utils.Fall}}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + result := utils.ParseTerm(tt.code) + if result != tt.expected { + t.Errorf("ParseTerm(%s) = %v, want %v", tt.code, result, tt.expected) + } + }) + } +} + +func TestTermToString(t *testing.T) { + tests := []struct { + term utils.Term + expected string + }{ + {utils.Term{Year: 2024, Season: utils.Fall}, "202410"}, + {utils.Term{Year: 2024, Season: utils.Spring}, "202420"}, + {utils.Term{Year: 2024, Season: utils.Summer}, "202430"}, + {utils.Term{Year: 2025, Season: utils.Fall}, "202510"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + result := tt.term.ToString() + if result != tt.expected { + t.Errorf("Term{Year: %d, Season: %d}.ToString() = %s, want %s", + tt.term.Year, tt.term.Season, result, tt.expected) + } + }) + } +} + +func TestDefault(t *testing.T) { + config.CentralTimeLocation, _ = time.LoadLocation("America/Chicago") + + tests := []struct { + name string + date time.Time + expected utils.Term + }{ + { + name: "During Spring term", + date: time.Date(2024, 3, 15, 12, 0, 0, 0, config.CentralTimeLocation), + expected: utils.Term{Year: 2024, Season: utils.Spring}, + }, + { + name: "Between terms - returns next term", + date: time.Date(2024, 5, 20, 12, 0, 0, 0, config.CentralTimeLocation), + expected: utils.Term{Year: 2024, Season: utils.Summer}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := utils.Default(tt.date) + if result != tt.expected { + t.Errorf("Default() = %v, want %v", result, tt.expected) + } + }) + } +} + +// Helper function to compare terms, handling nil cases +func termsEqual(a, b *utils.Term) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +}