Skip to content

Commit b2a9465

Browse files
committed
feat: add membership calcuation tests
1 parent 606076b commit b2a9465

File tree

5 files changed

+1888
-126
lines changed

5 files changed

+1888
-126
lines changed
Lines changed: 143 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,91 @@
1-
import { getAcademicStart, getNextAcademicStart } from "@dotkomonline/types"
1+
import { getNextAutumnSemesterStart, getAutumnSemesterStart, isSpringSemester } from "@dotkomonline/types"
22
import { differenceInMonths, subYears } from "date-fns"
33
import invariant from "tiny-invariant"
44
import type { NTNUGroup } from "../feide/feide-groups-repository"
55
import { getLogger } from "@dotkomonline/logger"
6-
import { getCurrentUTC } from "@dotkomonline/utils"
7-
import type { TZDate } from "@date-fns/tz"
86

97
export interface MembershipService {
10-
findEstimatedStudyStart(
11-
study: "BACHELOR" | "MASTER",
12-
courses: NTNUGroup[]
13-
): { start: TZDate; semester: number; grade: number }
8+
/**
9+
* Find the approximate semester based on a student's courses against a hard-coded set of courses.
10+
*
11+
* NOTE: The value is 0-indexed.
12+
*
13+
* Master studies begin at semester 6.
14+
*
15+
* @see getCourseStart(semester) in types package
16+
* @see getStudyGrade(semester) in types package
17+
*
18+
* @example
19+
* findEstimatedSemester(...) -> 0 // 1st semester Bachelor (Autumn)
20+
* findEstimatedSemester(...) -> 1 // 2nd semester Bachelor (Spring)
21+
* findEstimatedSemester(...) -> 2 // 3rd semester Bachelor
22+
* findEstimatedSemester(...) -> 3 // 4th semester Bachelor
23+
* findEstimatedSemester(...) -> 4 // 5th semester Bachelor
24+
* findEstimatedSemester(...) -> 5 // 6th semester Bachelor
25+
* findEstimatedSemester(...) -> 6 // 1st semester Master (regardless of prior Bachelor length)
26+
* findEstimatedSemester(...) -> 7 // 2nd semester Master
27+
* findEstimatedSemester(...) -> 8 // 3rd semester Master
28+
* findEstimatedSemester(...) -> 9 // 4th semester Master
29+
*/
30+
findEstimatedSemester(study: "BACHELOR" | "MASTER", courses: ReadonlyArray<NTNUGroup>): number
1431
}
1532

1633
const BACHELOR_STUDY_PLAN = [
1734
{
1835
semester: 0,
19-
courses: ["IT2805", "MA0001", "TDT4109"],
36+
courses: ["IT2805", "MA0001", "TDT4109", "EXPH0300"],
37+
minimumEnrolledCourses: 3,
2038
},
2139
{
2240
semester: 1,
2341
courses: ["MA0301", "TDT4100", "TDT4180", "TTM4100"],
42+
minimumEnrolledCourses: 3,
2443
},
2544
{
2645
semester: 2,
2746
courses: ["IT1901", "TDT4120", "TDT4160"],
47+
minimumEnrolledCourses: 3,
2848
},
2949
{
3050
semester: 3,
3151
courses: ["TDT4140", "TDT4145"],
52+
minimumEnrolledCourses: 2,
3253
},
3354
{
3455
semester: 4,
3556
// Semester 1 in year 3 are all elective courses, so we do not use any of them to determine which year somebody
3657
// started studying
3758
courses: [],
59+
minimumEnrolledCourses: 0,
3860
},
3961
{
4062
semester: 5,
4163
courses: ["IT2901"],
64+
minimumEnrolledCourses: 1,
4265
},
4366
] as const
4467

68+
export const MASTER_SEMESTER_OFFSET = 6 as const
4569
const MASTER_STUDY_PLAN = [
4670
{
47-
semester: 0,
71+
semester: MASTER_SEMESTER_OFFSET + 0,
4872
courses: [],
73+
minimumEnrolledCourses: 0,
4974
},
5075
{
51-
semester: 1,
76+
semester: MASTER_SEMESTER_OFFSET + 1,
5277
courses: [],
78+
minimumEnrolledCourses: 0,
5379
},
5480
{
55-
semester: 2,
81+
semester: MASTER_SEMESTER_OFFSET + 2,
5682
courses: ["IT3915"],
83+
minimumEnrolledCourses: 1,
5784
},
5885
{
59-
semester: 3,
86+
semester: MASTER_SEMESTER_OFFSET + 3,
6087
courses: ["IT3920"],
88+
minimumEnrolledCourses: 1,
6189
},
6290
] as const
6391

@@ -90,119 +118,127 @@ export function getMembershipService(): MembershipService {
90118
/**
91119
* Find the approximate semester based on a student's courses against a hard-coded set of courses.
92120
*/
93-
function findEstimatedSemester(courseSet: StudyPlanCourseSet, studentCourses: NTNUGroup[]): number {
94-
logger.info("Searching for applicable membership based on courses %o and study plan %o", studentCourses, courseSet)
95-
let largestSemester = 0
121+
function estimateSemester(
122+
courseSet: StudyPlanCourseSet,
123+
studentCourses: ReadonlyArray<NTNUGroup>,
124+
semesterOffset: number
125+
): number {
126+
let largestSemester = semesterOffset
127+
96128
for (let i = 0; i < courseSet.length; i++) {
97129
const semester = courseSet[i]
98130

99-
// Semesters with zero mandatory courses possibly increment `largestSemester` by determining the number of
100-
// years since the last mandatory courses. However, if the current semester we are iterating over is the first
101-
// in the study plan, we cannot get any more information that could be used to increment `largestSemester`, so
102-
// we do not try.
103-
const isFirstYear = i === 0 || i === 1
104-
105-
if (semester.courses.length === 0 && !isFirstYear) {
106-
const previousSemesters = courseSet.slice(0, i).filter((semester) => semester.courses.length !== 0)
107-
// By invariant that this is not the first year, and that there are maximum two semesters without mandatory
108-
// courses, `previousSemesters` is guaranteed to have at least one element.
109-
invariant(previousSemesters.length !== 0)
110-
111-
// We take the ceil(mean(distances)) across all past semesters.
112-
let largestLocalSemester = 0
113-
for (const previousSemester of previousSemesters.toReversed()) {
114-
// The criteria here is the same as below, except we also require the course to have a finished date.
115-
const hasPassedPreviousSemester = previousSemester.courses.every((course) =>
116-
studentCourses.some(
117-
(studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined
118-
)
119-
)
131+
// Has mandatory courses for this semester
132+
if (semester.courses.length > 0) {
133+
const mandatoryCoursesEnrolledIn = semester.courses.filter((course) =>
134+
studentCourses.some((studentCourse) => course === studentCourse.code)
135+
)
136+
137+
if (mandatoryCoursesEnrolledIn.length >= semester.minimumEnrolledCourses) {
138+
largestSemester = semester.semester
139+
continue
140+
} else {
141+
// If the user does not have all the courses required for this semester, we would much rather prefer to give
142+
// them a lower year than a higher one. Chances are a student would notify HS (basically our administration)
143+
// if they cannot attend events they should be able to, while someone might not notify HS about them being able
144+
// to attend company events they are not supposed to be at.
145+
break
146+
}
147+
}
148+
149+
// Since there are no mandatory courses for this course set, we need to estimate from the previous course sets.
150+
// To be able to determine this, there cannot be more than two semesters in a row without mandatory courses. This
151+
// is validated in `validateStudyPlanCourseSet`.
152+
const previousCourseSetsWithMandatoryCourses = courseSet
153+
.slice(0, i)
154+
.filter((semester) => semester.courses.length !== 0)
155+
156+
// If there are no previous mandatory courses, we cannot really make any estimation. We
157+
const previousMandatoryCoursesCount = previousCourseSetsWithMandatoryCourses.reduce(
158+
(acc, semester) => acc + semester.courses.length,
159+
0
160+
)
120161

121-
if (!hasPassedPreviousSemester) {
122-
continue
123-
}
124-
125-
// There is a scenario where a user failed a course in year 1, but passed in year 2. This is why we take the
126-
// mean distance, which is later ceiled.
127-
const previousSemesterDistances = previousSemester.courses.map((course) => {
128-
const studentCourse = studentCourses.find((studentCourse) => studentCourse.code === course)
129-
130-
// -1 because length is 1-indexed
131-
const semesterDistanceFromEnd = courseSet.length - 1 - previousSemester.semester
132-
// If you were supposed to finish your degree this year, how far away would the semester in question be?
133-
// For example; if previousSemester=3 (algdat+itp+datdig), then the distance would be 2.
134-
const years = Math.ceil(semesterDistanceFromEnd / 2)
135-
const courseEndAssumingLastYearStudent = subYears(getNextAcademicStart(), years)
136-
137-
// INVARIANT: The course should exist, and it should be finished according to `hasPassedPreviousSemester`.
138-
invariant(studentCourse !== undefined && studentCourse.finished !== undefined)
139-
// We divide by six because we have two school semesters in a year, effectively turnings months in a year
140-
// into semesters
141-
const distance = Math.floor(
142-
differenceInMonths(getAcademicStart(studentCourse.finished), courseEndAssumingLastYearStudent) / 6
143-
)
144-
145-
return previousSemester.semester + (semesterDistanceFromEnd - distance)
146-
})
147-
148-
// Take the mean distance for this semester
149-
const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0)
150-
const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length)
151-
152-
largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate)
162+
// If it is the second semester, and the first semester had no mandatory courses, we cannot really make any real
163+
// estimation. But we feel comfortable incrementing the semester by one, since it would still be the first year
164+
// (relative to the semester offset).
165+
if (previousMandatoryCoursesCount === 0) {
166+
// We need to determine if we are in spring or autumn semester to be able to increment correctly.
167+
const isEvenOffset = semesterOffset % 2 === 0
168+
const isSpring = isSpringSemester()
169+
170+
// In practice, with the current (2026) Master study plan, the first two semesters have no mandatory courses,
171+
// but we would still like them to be put into semester 6 and 7 respectively.
172+
// - If even offset (start autumn) we increment if it is currently spring.
173+
// - If odd offset (start spring) we increment if it is currently autumn.
174+
// NOTE: This scenario is not possible with the current study plans, but we keep it for future-proofing.
175+
if (isEvenOffset === isSpring) {
176+
largestSemester = semesterOffset + 1
177+
} else {
178+
largestSemester = semesterOffset
153179
}
154180

155-
largestSemester = Math.max(largestSemester, largestLocalSemester)
156181
continue
157182
}
158183

159-
// If the user has all the courses that are mandatory
160-
const isGroupMemberOfMandatoryCourses = semester.courses.every((course) =>
161-
studentCourses.some((studentCourse) => course === studentCourse.code)
162-
)
184+
// By invariant that this is not the first year, and that there are maximum two semesters without mandatory
185+
// courses, `previousSemesters` is guaranteed to have at least one element.
186+
invariant(previousCourseSetsWithMandatoryCourses.length !== 0)
163187

164-
if (isGroupMemberOfMandatoryCourses) {
165-
largestSemester = semester.semester
166-
} else {
167-
// If the user does not have all the courses required for this semester, we would much rather prefer to give
168-
// them a lower year than a higher one. Chances are a student would notify HS (basically our administration)
169-
// if they cannot attend events they should be able to, while someone might not notify HS about them being able
170-
// to attend company events they are not supposed to be at.
171-
break
188+
// We take the ceil(mean(distances)) across all past semesters.
189+
let largestLocalSemester = 0
190+
for (const previousSemester of previousCourseSetsWithMandatoryCourses.toReversed()) {
191+
// The criteria here is the same as below, except we also require the course to have a finished date.
192+
const hasPassedPreviousSemester = previousSemester.courses.every((course) =>
193+
studentCourses.some((studentCourse) => course === studentCourse.code && studentCourse.finished !== undefined)
194+
)
195+
196+
if (!hasPassedPreviousSemester) {
197+
continue
198+
}
199+
200+
// There is a scenario where a user failed a course in year 1, but passed in year 2. This is why we take the
201+
// mean distance, which is later ceiled.
202+
const previousSemesterDistances = previousSemester.courses.map((course) => {
203+
const studentCourse = studentCourses.find((studentCourse) => studentCourse.code === course)
204+
205+
// -1 because length is 1-indexed
206+
const semesterDistanceFromEnd = courseSet.length - 1 - previousSemester.semester
207+
// If you were supposed to finish your degree this year, how far away would the semester in question be?
208+
// For example; if previousSemester=3 (algdat+itp+datdig), then the distance would be 2.
209+
const years = Math.ceil(semesterDistanceFromEnd / 2)
210+
const courseEndAssumingLastYearStudent = subYears(getNextAutumnSemesterStart(), years)
211+
212+
// INVARIANT: The course should exist, and it should be finished according to `hasPassedPreviousSemester`.
213+
invariant(studentCourse !== undefined && studentCourse.finished !== undefined)
214+
// We divide by six because we have two school semesters in a year, effectively turnings months in a year
215+
// into semesters
216+
const distance = Math.floor(
217+
differenceInMonths(getAutumnSemesterStart(studentCourse.finished), courseEndAssumingLastYearStudent) / 6
218+
)
219+
220+
return previousSemester.semester + (semesterDistanceFromEnd - distance)
221+
})
222+
223+
// Take the mean distance for this semester
224+
const sum = previousSemesterDistances.reduce((acc, curr) => acc + curr, 0)
225+
const currentSemesterEstimate = Math.ceil(sum / previousSemesterDistances.length)
226+
227+
largestLocalSemester = Math.max(largestLocalSemester, currentSemesterEstimate)
172228
}
229+
230+
largestSemester = Math.max(largestSemester, largestLocalSemester)
173231
}
174232

175233
return largestSemester
176234
}
177235

178236
return {
179-
findEstimatedStudyStart(study, courses) {
237+
findEstimatedSemester(study, courses) {
180238
const studyPlan = study === "MASTER" ? MASTER_STUDY_PLAN : BACHELOR_STUDY_PLAN
181-
const semester = findEstimatedSemester(studyPlan, courses)
182-
183-
// We use Math#round because it will give us the correct year delta:
184-
// Year 1 autumn (value 0) : round(0 / 2) = 0 (Start year = current year)
185-
// Year 1 spring (value 1): round(1 / 2) = 1 (Start year = current - 1, since spring is in the next calendar year)
186-
// Year 2 autumn (value 2) : round(2 / 2) = 1 (Start year = current - 1)
187-
// Year 2 spring (value 3): round(3 / 2) = 2 (Start year = current - 2)
188-
// ...
189-
const yearOffset = Math.round(semester / 2)
190-
const start = subYears(getAcademicStart(getCurrentUTC()), yearOffset)
191-
192-
// A school year consists of two semesters (Autumn and Spring). So this formula will give us the year:
193-
// Year 1 autumn (value 0): floor(0 / 2) + 1 = 1 (Year 1)
194-
// Year 1 spring (value 1): floor(1 / 2) + 1 = 1 (Year 1)
195-
// Year 2 autumn (value 2): floor(2 / 2) + 1 = 2 (Year 2)
196-
// Year 2 spring (value 3): floor(3 / 2) + 1 = 2 (Year 2)
197-
// Year 3 autumn (value 4): floor(4 / 2) + 1 = 3 (Year 3)
198-
// ...
199-
const grade = Math.floor(semester / 2) + 1
200-
201-
return {
202-
semester,
203-
start,
204-
grade,
205-
}
239+
const offset = study === "MASTER" ? MASTER_SEMESTER_OFFSET : 0
240+
241+
return estimateSemester(studyPlan, courses, offset)
206242
},
207243
}
208244
}

0 commit comments

Comments
 (0)