diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d17db44..6633398 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,8 +1,6 @@ name: CI on: - push: - branches: [ main ] pull_request: branches: [ main ] diff --git a/.gitignore b/.gitignore index e8fece7..07fe838 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,7 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + + +# docs of local picture +docs \ No newline at end of file diff --git a/syncortexGA/constraints/hard_constraints.py b/syncortexGA/constraints/hard_constraints.py index e69de29..a7322ff 100644 --- a/syncortexGA/constraints/hard_constraints.py +++ b/syncortexGA/constraints/hard_constraints.py @@ -0,0 +1,27 @@ +from syncortexGA.models.timetable_model import Timetable + + +def check_h1_instructor_conflicts(timetable: Timetable) -> bool: + """ + Ensures no instructor is scheduled for more than one session at the same time + and only in their available time slots. + """ + seen = {} # key: (instructor_id, day, start, end), value: count + + for session in timetable.sessions: + instructor = session.instructor_id + slot = session.slot + + # 1. Check if the session is in instructor's available slots + if slot not in instructor.available_slots: + return False + + # 2. Check if this slot already assigned to this instructor + key = (instructor.id, slot.day, slot.start, slot.end) + if key in seen: + return ( + False # Conflict detected: instructor already has session in this slot + ) + seen[key] = True + + return True diff --git a/syncortexGA/models/timetable_model.py b/syncortexGA/models/timetable_model.py index c8db99e..2a727c4 100644 --- a/syncortexGA/models/timetable_model.py +++ b/syncortexGA/models/timetable_model.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, model_validator, StringConstraints +from pydantic import BaseModel, model_validator, StringConstraints, ConfigDict from typing import List, Optional, Literal, Annotated # Accepts only time strings in 24-hour format like "08:00", "23:59" @@ -39,15 +39,31 @@ class AlternatingSessionPattern(BaseModel): paired_course_id: int +class LabSessionPattern(BaseModel): + """ + Represents a lab session pattern that occurs weekly at a fixed time. + This is used for lab courses that do not have a session pattern. + """ + + slot: TimeSlot + + @model_validator(mode="after") + def check_single_slot(cls, model): + if not model.slot: + raise ValueError("LabSessionPattern must have exactly 1 slot") + return model + + class SessionPattern(BaseModel): """ Wraps either a fixed or alternating session pattern. Only one of the patterns must be set based on the type. """ - type: Literal["fixed", "alternating"] + type: Literal["fixed", "alternating", "lab"] fixed_pattern: Optional[FixedSessionPattern] = None alternating_pattern: Optional[AlternatingSessionPattern] = None + lab_pattern: Optional[LabSessionPattern] = None @model_validator(mode="after") def validate_only_one_pattern(cls, model): @@ -59,56 +75,24 @@ def validate_only_one_pattern(cls, model): raise ValueError( "For type='alternating', " "only alternating_pattern must be set" ) + elif model.type == "lab": + if ( + not model.lab_pattern + or model.fixed_pattern + or model.alternating_pattern + ): + raise ValueError("For type='lab', only lab_pattern must be set") return model -# ========== Course ========== -class Course(BaseModel): - """ - Represents a course that may or may not include a lab. - Lab courses must define a lab slot and cannot have a session pattern. - Non-lab courses must define a session pattern and cannot have a lab slot. - """ +# ========== StudentGroup ========== +class StudentGroup(BaseModel): + """Represents a group of students (e.g., a class or cohort).""" id: int - name: str - code: str - instructor_id: int - has_lab: bool = False - lab_slot: Optional[TimeSlot] = None - lab_room_id: Optional[int] = None - session_pattern: Optional[SessionPattern] = None - - @model_validator(mode="after") - def validate_course(cls, model): - if model.has_lab: - if not model.lab_slot: - raise ValueError("Lab slot must be defined for lab courses") - if model.session_pattern: - raise ValueError("Lab courses must not have session pattern") - else: - if not model.session_pattern: - raise ValueError("Non-lab courses must have a session pattern") - if model.lab_slot: - raise ValueError("Non-lab courses must not have lab slot") - return model - - -class CourseOffering(BaseModel): - """ - Represents the offering of a course by an instructor for a specific subgroup. - Allows multiple instructors to offer the same course to different subgroups, - and a single instructor to teach the same course in multiple subgroups. - - Attributes: - course_id (int): Identifier of the course being offered. - instructor_id (int): Identifier of the instructor teaching the course. - sub_group (int): Sub-group number within the course offering. Default is 1. - """ - - course_id: int - instructor_id: int - sub_group: int = 1 # Default subgroup is 1 + name: str # e.g., "CS-401" + major: str # e.g., "Computer Engineering" + entry_year: int # e.g., 2022 # ========== Student ========== @@ -117,17 +101,7 @@ class Student(BaseModel): full_name: str student_number: str # e.g., "401234567" - group_id: int # Refers to StudentGroup.id - - -# ========== StudentGroup ========== -class StudentGroup(BaseModel): - """Represents a group of students (e.g., a class or cohort).""" - - id: int - name: str # e.g., "CS-401" - major: str # e.g., "Computer Engineering" - entry_year: int # e.g., 2022 + group_id: StudentGroup # Refers to StudentGroup.id # ========== Instructor ========== @@ -150,17 +124,65 @@ class Room(BaseModel): is_lab: bool = False +# ========== Course ========== +class Course(BaseModel): + """ + Represents a course that may or may not include a lab. + Lab courses must define a lab slot and cannot have a session pattern. + Non-lab courses must define a session pattern and cannot have a lab slot. + """ + + id: int + name: str + code: str + # has_lab: bool = False + # lab_room_id: Optional[int] = None + session_pattern: Optional[SessionPattern] = None + + +class CourseOffering(BaseModel): + """ + Represents the offering of a course by an instructor for a specific subgroup. + Allows multiple instructors to offer the same course to different subgroups, + and a single instructor to teach the same course in multiple subgroups. + + Attributes: + course_id (int): Identifier of the course being offered. + instructor_id (int): Identifier of the instructor teaching the course. + sub_group (int): Sub-group number within the course offering. Default is 1. + """ + + course_id: Course + instructor_id: Instructor + sub_group: int = 1 # Default subgroup is 1 + + # ========== Scheduled Session & Timetable ========== class ScheduledSession(BaseModel): """Represents a scheduled class session (theory or lab).""" - course_id: int - group_id: int - instructor_id: int - room_id: int + course_id: Course + group_id: StudentGroup + instructor_id: Instructor + room_id: Room slot: TimeSlot type: Literal["theory", "lab"] + @model_validator(mode="after") + def validate_slot(cls, model): + if model.type == "lab" and not model.course_id.session_pattern: + raise ValueError("Lab sessions must have a lab session pattern defined") + elif model.type == "theory" and not model.course_id.session_pattern: + raise ValueError("Theory sessions must have a session pattern defined") + elif model.type not in ("theory", "lab"): + raise ValueError("Session type must be either 'theory' or 'lab'") + + if model.type == "lab" != model.course_id.session_pattern.type: + raise ValueError("The Type of Pattern must match the session type") + if model.type == "theory" and model.course_id.session_pattern.type == "lab": + raise ValueError("Theory sessions cannot have a lab session pattern") + return model + class Timetable(BaseModel): """Represents the final schedule as a list of sessions.""" diff --git a/tests/unit/test_constraints.py b/tests/unit/test_constraints.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/test_hard_constraints.py b/tests/unit/test_hard_constraints.py new file mode 100644 index 0000000..5dd5e1e --- /dev/null +++ b/tests/unit/test_hard_constraints.py @@ -0,0 +1,178 @@ +import pytest +from syncortexGA.models.timetable_model import ( + TimeSlot, + Instructor, + StudentGroup, + Room, + Course, + SessionPattern, + FixedSessionPattern, + LabSessionPattern, + ScheduledSession, + Timetable, +) +from syncortexGA.constraints.hard_constraints import check_h1_instructor_conflicts + + +# ---------- Common Fixtures ---------- + + +@pytest.fixture +def common_setup(): + """ + Creates a standard setup of group, room, instructor, course, and time slots + used in multiple tests. + """ + group = StudentGroup(id=1, name="CS-A", major="CS", entry_year=2023) + + room = Room(id=1, name="Room 101", capacity=40, is_lab=False) + + slot1 = TimeSlot(day="Saturday", start="08:00", end="10:00") + slot2 = TimeSlot(day="Saturday", start="10:00", end="12:00") + slot3 = TimeSlot(day="Sunday", start="08:00", end="10:00") # Not available + + instructor = Instructor( + id=1, + full_name="Dr. Smith", + available_slots=[slot1, slot2], # Only slot1 and slot2 are allowed + preferred_slots=None, + ) + + course = Course( + id=1, + name="Algorithms", + code="CS101", + session_pattern=SessionPattern( + type="fixed", fixed_pattern=FixedSessionPattern(slots=[slot1, slot2]) + ), + ) + + return group, room, instructor, course, slot1, slot2, slot3 + + +# ---------- Test Cases for H1 ---------- + + +def test_h1_valid_schedule(common_setup): + """ + Test: Instructor has two valid sessions with no slot conflict. + Expect: H1 passes (returns True) + """ + group, room, instructor, course, slot1, slot2, _ = common_setup + + sessions = [ + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot1, + type="theory", + ), + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot2, + type="theory", + ), + ] + + timetable = Timetable(sessions=sessions) + assert check_h1_instructor_conflicts(timetable) is True + + +def test_h1_invalid_due_to_slot_not_in_available(common_setup): + """ + Test: Instructor is scheduled in a slot not listed in available_slots. + Expect: H1 fails (returns False) + """ + group, room, instructor, course, _, _, slot3 = common_setup # slot3 is invalid + + session = ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot3, + type="theory", + ) + + timetable = Timetable(sessions=[session]) + assert check_h1_instructor_conflicts(timetable) is False + + +def test_h1_invalid_due_to_slot_conflict(common_setup): + """ + Test: Instructor has two sessions scheduled in the exact same time slot. + Expect: H1 fails (returns False) + """ + group, room, instructor, course, slot1, _, _ = common_setup + + sessions = [ + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot1, + type="theory", + ), + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot1, + type="theory", + ), # Conflict + ] + + timetable = Timetable(sessions=sessions) + assert check_h1_instructor_conflicts(timetable) is False + + +def test_h1_multiple_instructors_no_conflict(): + """ + Test: Two instructors teaching different sessions at the same time. + Expect: H1 passes (returns True) — conflict is per instructor, not globally + """ + slot = TimeSlot(day="Monday", start="10:00", end="12:00") + + instructor1 = Instructor(id=1, full_name="A", available_slots=[slot]) + instructor2 = Instructor(id=2, full_name="B", available_slots=[slot]) + + group = StudentGroup(id=1, name="G1", major="CS", entry_year=2023) + room = Room(id=1, name="Room A", capacity=30, is_lab=False) + + course = Course( + id=1, + name="CS", + code="CS101", + session_pattern=SessionPattern( + type="lab", lab_pattern=LabSessionPattern(slot=slot) + ), + ) + + sessions = [ + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor1, + room_id=room, + slot=slot, + type="lab", + ), + ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor2, + room_id=room, + slot=slot, + type="lab", + ), + ] + + timetable = Timetable(sessions=sessions) + assert check_h1_instructor_conflicts(timetable) is True diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index f2d2a9c..7dcc1fa 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -4,23 +4,122 @@ TimeSlot, FixedSessionPattern, AlternatingSessionPattern, + LabSessionPattern, SessionPattern, - Course, - CourseOffering, - Student, StudentGroup, + Student, Instructor, Room, + Course, + CourseOffering, ScheduledSession, Timetable, ) -# ====== TimeSlot Tests ====== +# ==== Helper functions to create reusable sample data ==== + + +def sample_timeslot(day="Monday", start="08:00", end="10:00"): + return TimeSlot(day=day, start=start, end=end) + + +def sample_student_group(): + return StudentGroup( + id=1, name="CS-401", major="Computer Engineering", entry_year=2022 + ) + + +def sample_instructor(): + return Instructor( + id=1, + full_name="Dr. Smith", + available_slots=[sample_timeslot()], + preferred_slots=[sample_timeslot("Tuesday", "10:00", "12:00")], + ) + + +def sample_room(): + return Room(id=1, name="Room 101", capacity=40, is_lab=False) + + +def sample_fixed_session_pattern(): + return FixedSessionPattern( + slots=[ + sample_timeslot("Saturday", "08:00", "10:00"), + sample_timeslot("Monday", "10:00", "12:00"), + ] + ) + + +def sample_alternating_session_pattern(): + return AlternatingSessionPattern( + fixed_slot=sample_timeslot("Sunday", "08:00", "10:00"), + alternating_slot=sample_timeslot("Tuesday", "10:00", "12:00"), + alternating_mode="odd", + paired_course_id=123, + ) + + +def sample_lab_session_pattern(): + return LabSessionPattern(slot=sample_timeslot("Wednesday", "14:00", "16:00")) + + +def sample_session_pattern_fixed(): + return SessionPattern(type="fixed", fixed_pattern=sample_fixed_session_pattern()) + + +def sample_session_pattern_alternating(): + return SessionPattern( + type="alternating", alternating_pattern=sample_alternating_session_pattern() + ) + + +def sample_session_pattern_lab(): + return SessionPattern(type="lab", lab_pattern=sample_lab_session_pattern()) +def sample_course_with_pattern(session_pattern=None): + return Course( + id=1, + name="Advanced Mathematics", + code="MATH401", + session_pattern=session_pattern, + ) + + +def sample_course_without_pattern(): + return Course(id=2, name="Philosophy", code="PHIL101", session_pattern=None) + + +def sample_course_offering(course=None, instructor=None, sub_group=1): + return CourseOffering( + course_id=course, instructor_id=instructor, sub_group=sub_group + ) + + +def sample_scheduled_session(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + group = sample_student_group() + instructor = sample_instructor() + room = sample_room() + slot = sample_timeslot() + return ScheduledSession( + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot, + type="theory", + ) + + +# ==== Tests ==== + + +# ----- TimeSlot ----- def test_timeslot_valid(): - ts = TimeSlot(day="Monday", start="08:00", end="10:00") + ts = sample_timeslot() assert ts.day == "Monday" assert ts.start == "08:00" assert ts.end == "10:00" @@ -28,31 +127,23 @@ def test_timeslot_valid(): def test_timeslot_invalid_day(): with pytest.raises(ValidationError): - TimeSlot(day="Friday", start="08:00", end="10:00") + TimeSlot(day="Friday", start="08:00", end="10:00") # Friday not allowed def test_timeslot_invalid_time_format(): with pytest.raises(ValidationError): - TimeSlot(day="Monday", start="8am", end="10am") - - -# ====== FixedSessionPattern Tests ====== + TimeSlot(day="Monday", start="8am", end="10:00") +# ----- FixedSessionPattern ----- def test_fixed_session_pattern_valid(): - slots = [ - TimeSlot(day="Saturday", start="08:00", end="10:00"), - TimeSlot(day="Monday", start="10:00", end="12:00"), - ] - pattern = FixedSessionPattern(slots=slots) + pattern = sample_fixed_session_pattern() assert len(pattern.slots) == 2 def test_fixed_session_pattern_invalid_count(): with pytest.raises(ValidationError): - FixedSessionPattern( - slots=[TimeSlot(day="Saturday", start="08:00", end="10:00")] - ) + FixedSessionPattern(slots=[sample_timeslot()]) def test_fixed_session_pattern_empty(): @@ -60,52 +151,52 @@ def test_fixed_session_pattern_empty(): FixedSessionPattern(slots=[]) -# ====== AlternatingSessionPattern Tests ====== - - +# ----- AlternatingSessionPattern ----- def test_alternating_session_pattern_valid(): - pattern = AlternatingSessionPattern( - fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), - alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), - alternating_mode="odd", - paired_course_id=123, - ) - assert pattern.alternating_mode == "odd" + pattern = sample_alternating_session_pattern() + assert pattern.alternating_mode in ("odd", "even") def test_alternating_session_pattern_invalid_mode(): with pytest.raises(ValidationError): AlternatingSessionPattern( - fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), - alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), - alternating_mode="week3", - paired_course_id=123, + fixed_slot=sample_timeslot(), + alternating_slot=sample_timeslot(), + alternating_mode="weekly", + paired_course_id=1, ) -# ====== SessionPattern Tests ====== +# ----- LabSessionPattern ----- +def test_lab_session_pattern_valid(): + pattern = sample_lab_session_pattern() + assert pattern.slot.day == "Wednesday" + + +def test_lab_session_pattern_invalid_missing_slot(): + with pytest.raises(ValidationError): + LabSessionPattern(slot=None) +# ----- SessionPattern ----- def test_session_pattern_fixed_valid(): - fixed = FixedSessionPattern( - slots=[ - TimeSlot(day="Saturday", start="08:00", end="10:00"), - TimeSlot(day="Monday", start="10:00", end="12:00"), - ] - ) - sp = SessionPattern(type="fixed", fixed_pattern=fixed) + sp = sample_session_pattern_fixed() assert sp.type == "fixed" + assert sp.fixed_pattern is not None + assert sp.alternating_pattern is None + assert sp.lab_pattern is None def test_session_pattern_alternating_valid(): - alt = AlternatingSessionPattern( - fixed_slot=TimeSlot(day="Sunday", start="08:00", end="10:00"), - alternating_slot=TimeSlot(day="Tuesday", start="10:00", end="12:00"), - alternating_mode="even", - paired_course_id=99, - ) - sp = SessionPattern(type="alternating", alternating_pattern=alt) + sp = sample_session_pattern_alternating() assert sp.type == "alternating" + assert sp.alternating_pattern is not None + + +def test_session_pattern_lab_valid(): + sp = sample_session_pattern_lab() + assert sp.type == "lab" + assert sp.lab_pattern is not None def test_session_pattern_invalid_type(): @@ -113,216 +204,145 @@ def test_session_pattern_invalid_type(): SessionPattern(type="random") -def test_session_pattern_mismatch_fixed(): - fixed = FixedSessionPattern( - slots=[ - TimeSlot(day="Saturday", start="08:00", end="10:00"), - TimeSlot(day="Monday", start="10:00", end="12:00"), - ] - ) - alt = AlternatingSessionPattern( - fixed_slot=fixed.slots[0], - alternating_slot=fixed.slots[1], - alternating_mode="odd", - paired_course_id=5, - ) - with pytest.raises(ValidationError): - SessionPattern(type="fixed", fixed_pattern=fixed, alternating_pattern=alt) +def test_session_pattern_mismatched_patterns(): + fixed = sample_fixed_session_pattern() + alt = sample_alternating_session_pattern() + lab = sample_lab_session_pattern() + # fixed type but alternating pattern set + with pytest.raises(ValidationError): + SessionPattern( + type="fixed", fixed_pattern=fixed, alternating_pattern=alt, lab_pattern=lab + ) -def test_session_pattern_mismatch_alternating(): - fixed = FixedSessionPattern( - slots=[ - TimeSlot(day="Saturday", start="08:00", end="10:00"), - TimeSlot(day="Monday", start="10:00", end="12:00"), - ] - ) + # alternating type but fixed pattern set with pytest.raises(ValidationError): SessionPattern(type="alternating", fixed_pattern=fixed) - -# ====== Course Tests ====== - - -def test_course_lab_valid(): - course = Course( - id=1, - name="Physics Lab", - code="PHY101", - instructor_id=10, - has_lab=True, - lab_slot=TimeSlot(day="Wednesday", start="14:00", end="16:00"), - lab_room_id=3, - lab_instructor_id=15, - ) - assert course.has_lab is True - - -def test_course_lab_missing_lab_slot(): + # lab type but fixed pattern set with pytest.raises(ValidationError): - Course( - id=2, name="Chemistry Lab", code="CHEM101", instructor_id=11, has_lab=True - ) - + SessionPattern(type="lab", fixed_pattern=fixed) -def test_course_lab_with_session_pattern(): - lab_slot = TimeSlot(day="Wednesday", start="14:00", end="16:00") - session_pattern = SessionPattern( - type="fixed", - fixed_pattern=FixedSessionPattern( - slots=[ - TimeSlot(day="Monday", start="08:00", end="10:00"), - TimeSlot(day="Thursday", start="08:00", end="10:00"), - ] - ), - ) + # lab type but alternating pattern set with pytest.raises(ValidationError): - Course( - id=3, - name="Bio Lab", - code="BIO101", - instructor_id=12, - has_lab=True, - lab_slot=lab_slot, - session_pattern=session_pattern, - ) + SessionPattern(type="lab", alternating_pattern=alt) -def test_course_non_lab_valid(): - session_pattern = SessionPattern( - type="fixed", - fixed_pattern=FixedSessionPattern( - slots=[ - TimeSlot(day="Monday", start="08:00", end="10:00"), - TimeSlot(day="Thursday", start="08:00", end="10:00"), - ] - ), - ) - course = Course( - id=4, - name="Math", - code="MATH101", - instructor_id=13, - has_lab=False, - session_pattern=session_pattern, - ) - assert course.has_lab is False +# ----- StudentGroup ----- +def test_student_group_valid(): + group = sample_student_group() + assert group.name == "CS-401" -def test_course_non_lab_missing_session_pattern(): - with pytest.raises(ValidationError): - Course(id=5, name="History", code="HIST101", instructor_id=14, has_lab=False) +# ----- Student ----- +def test_student_valid(): + group = sample_student_group() + student = Student( + full_name="Alice Smith", student_number="401234567", group_id=group + ) + assert student.group_id.id == group.id -def test_course_non_lab_with_lab_slot(): +def test_student_missing_fields(): with pytest.raises(ValidationError): - Course( - id=6, - name="English", - code="ENG101", - instructor_id=15, - has_lab=False, - lab_slot=TimeSlot(day="Wednesday", start="14:00", end="16:00"), - ) - - -# ====== Student Tests ====== + Student(full_name="Bob") -def test_student_valid(): - student = Student(full_name="Alice Smith", student_number="401234567", group_id=100) - assert student.student_number == "401234567" +# ----- Instructor ----- +def test_instructor_valid(): + instructor = sample_instructor() + assert instructor.full_name == "Dr. Smith" + assert len(instructor.available_slots) == 1 -def test_student_missing_field(): - with pytest.raises(ValidationError): - Student(full_name="Bob") +def test_instructor_optional_preferred_slots(): + inst = Instructor(id=2, full_name="Jane Doe", available_slots=[sample_timeslot()]) + assert inst.preferred_slots is None -# ====== StudentGroup Tests ====== +# ----- Room ----- +def test_room_valid(): + room = sample_room() + assert room.capacity == 40 + assert room.is_lab is False -def test_student_group_valid(): - group = StudentGroup( - id=1, name="CS-401", major="Computer Engineering", entry_year=2023 - ) - assert group.name == "CS-401" +def test_room_lab_flag_true(): + room = Room(id=10, name="Lab 1", capacity=20, is_lab=True) + assert room.is_lab is True -# ====== Instructor Tests ====== +# ----- Course ----- +def test_course_with_valid_session_pattern(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + assert course.session_pattern is not None -def test_instructor_valid(): - instructor = Instructor( - id=1, - full_name="Dr. John", - available_slots=[TimeSlot(day="Monday", start="08:00", end="12:00")], - preferred_slots=[TimeSlot(day="Monday", start="10:00", end="12:00")], - ) - assert instructor.full_name == "Dr. John" +def test_course_without_session_pattern(): + course = sample_course_without_pattern() + assert course.session_pattern is None -def test_instructor_no_preferred(): - instructor = Instructor( - id=2, - full_name="Dr. Jane", - available_slots=[TimeSlot(day="Tuesday", start="08:00", end="10:00")], - ) - assert instructor.preferred_slots is None +# ----- CourseOffering ----- +def test_course_offering_valid_default_subgroup(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + instructor = sample_instructor() + offering = sample_course_offering(course=course, instructor=instructor) + assert offering.sub_group == 1 -# ====== Room Tests ====== +def test_course_offering_valid_custom_subgroup(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + instructor = sample_instructor() + offering = sample_course_offering(course=course, instructor=instructor, sub_group=3) + assert offering.sub_group == 3 -def test_room_valid(): - room = Room(id=1, name="Room 101", capacity=30, is_lab=False) - assert room.capacity == 30 +def test_course_offering_missing_course_id(): + instructor = sample_instructor() + with pytest.raises(ValidationError): + CourseOffering(instructor_id=instructor) -def test_room_lab_flag(): - room = Room(id=2, name="Lab 1", capacity=20, is_lab=True) - assert room.is_lab is True +def test_course_offering_missing_instructor_id(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + with pytest.raises(ValidationError): + CourseOffering(course_id=course) -# ====== ScheduledSession Tests ====== +def test_course_offering_invalid_subgroup_type(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + instructor = sample_instructor() + with pytest.raises(ValidationError): + CourseOffering(course_id=course, instructor_id=instructor, sub_group="two") +# ----- ScheduledSession ----- def test_scheduled_session_valid(): - session = ScheduledSession( - course_id=1, - group_id=10, - instructor_id=5, - room_id=3, - slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), - type="theory", - ) - assert session.type == "theory" + session = sample_scheduled_session() + assert session.type in ("theory", "lab") def test_scheduled_session_invalid_type(): + course = sample_course_with_pattern(sample_session_pattern_fixed()) + group = sample_student_group() + instructor = sample_instructor() + room = sample_room() + slot = sample_timeslot() with pytest.raises(ValidationError): ScheduledSession( - course_id=1, - group_id=10, - instructor_id=5, - room_id=3, - slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), + course_id=course, + group_id=group, + instructor_id=instructor, + room_id=room, + slot=slot, type="exam", ) -# ====== Timetable Tests ====== - - +# ----- Timetable ----- def test_timetable_valid(): - session = ScheduledSession( - course_id=1, - group_id=10, - instructor_id=5, - room_id=3, - slot=TimeSlot(day="Thursday", start="10:00", end="12:00"), - type="lab", - ) + session = sample_scheduled_session() timetable = Timetable(sessions=[session]) assert len(timetable.sessions) == 1 @@ -330,33 +350,3 @@ def test_timetable_valid(): def test_timetable_empty(): timetable = Timetable(sessions=[]) assert timetable.sessions == [] - - -def test_course_offering_valid_defaults(): - offering = CourseOffering(course_id=10, instructor_id=5) - assert offering.course_id == 10 - assert offering.instructor_id == 5 - assert offering.sub_group == 1 # Default value - - -def test_course_offering_valid_with_subgroup(): - offering = CourseOffering(course_id=20, instructor_id=7, sub_group=3) - assert offering.course_id == 20 - assert offering.instructor_id == 7 - assert offering.sub_group == 3 - - -def test_course_offering_invalid_missing_course_id(): - with pytest.raises(ValidationError): - CourseOffering(instructor_id=1) - - -def test_course_offering_invalid_missing_instructor_id(): - with pytest.raises(ValidationError): - CourseOffering(course_id=1) - - -def test_course_offering_invalid_sub_group_type(): - # sub_group should be int - with pytest.raises(ValidationError): - CourseOffering(course_id=1, instructor_id=1, sub_group="two")