Coverage for tests/test_courses_models.py: 100%

72 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-13 19:28 +0000

1"""Normalized course structure, typed matchers and array query semantics.""" 

2 

3import pytest 

4from conftest import course_payload, make_course 

5from pydantic import ValidationError 

6 

7from data_api.domain.courses.models import Condition, Conditions 

8from data_api.domain.courses.services import prepare_catalog, prepare_courses 

9from data_api.models.courses import CourseCondition, CourseData 

10 

11 

12def test_complete_normalized_course_and_union_shapes(): 

13 payload = course_payload( 

14 lecturers=[{"label": "Historical teacher"}, {"name_zh": "姓名", "name_en": None}], 

15 meetings=[{"classroom": None, "time": "bad", "time_codes": None}], 

16 credits=None, 

17 language_code=None, 

18 is_cancelled=None, 

19 parsing_issues=["Unrecognized time code"], 

20 ) 

21 assert CourseData.model_validate(payload).model_dump(mode="json") == payload 

22 assert len(payload) == 33 

23 

24 

25@pytest.mark.parametrize( 

26 "overrides", 

27 [ 

28 {"id": ""}, 

29 {"id": "11410TEST100000"}, 

30 {"semester_code": "11510"}, 

31 {"chinese_title": "", "english_title": " "}, 

32 {"credits": True}, 

33 {"credits": -1}, 

34 {"credits": float("inf")}, 

35 {"capacity": 1.5}, 

36 {"capacity": True}, 

37 {"enrolled_count": -1}, 

38 {"is_cancelled": "false"}, 

39 {"language_code": "中"}, 

40 {"lecturers": [{"label": "A", "name_en": "B"}]}, 

41 {"lecturers": [{"name_zh": "A"}]}, 

42 {"meetings": [{"classroom": "R", "time": "M1"}]}, 

43 {"programs": "Program"}, 

44 ], 

45) 

46def test_invalid_candidates_rejected(overrides): 

47 candidates = [course_payload(**overrides)] 

48 with pytest.raises(ValidationError): 

49 prepare_courses(candidates) 

50 

51 

52@pytest.mark.parametrize("field", list(course_payload())) 

53def test_missing_published_fields_are_not_silently_filled(field): 

54 payload = course_payload() 

55 del payload[field] 

56 with pytest.raises(ValidationError): 

57 prepare_courses([payload]) 

58 

59 

60def test_snapshot_identity_and_semester_validation(): 

61 from data_api.data.nthudata import FetchFailure 

62 

63 duplicate_courses = [course_payload(), course_payload()] 

64 with pytest.raises(FetchFailure, match="validation"): 

65 prepare_courses(duplicate_courses) 

66 single_course = [course_payload()] 

67 with pytest.raises(FetchFailure, match="validation"): 

68 prepare_courses(single_course, "11420") 

69 with pytest.raises(FetchFailure, match="payload_type"): 

70 prepare_courses({}) 

71 assert prepare_courses([]) == [] 

72 assert prepare_catalog({"semesters": ["11420", "11510"], "ignored": True}) == ["11510", "11420"] 

73 for semesters in [["11510", "11510"], ["bad"], [11510]]: 

74 with pytest.raises(ValidationError): 

75 prepare_catalog({"semesters": semesters}) 

76 

77 

78@pytest.mark.parametrize( 

79 "field,matcher,regex", 

80 [ 

81 ("credits", "3", False), 

82 ("credits", True, False), 

83 ("credits", float("nan"), False), 

84 ("capacity", 3.5, False), 

85 ("capacity", -1, False), 

86 ("is_cancelled", 0, False), 

87 ("chinese_title", 3, False), 

88 ("id", None, False), 

89 ("credits", 3, True), 

90 ("credits", "3", True), 

91 ("note", None, True), 

92 ("unknown", "x", False), 

93 ("teacher", "x", False), 

94 ], 

95) 

96def test_matcher_type_validation(field, matcher, regex): 

97 with pytest.raises(ValidationError): 

98 CourseCondition(row_field=field, matcher=matcher, regex_match=regex) 

99 

100 

101def test_array_leaves_and_null_do_not_match_absent_keys_or_empty_arrays(): 

102 course = make_course( 

103 lecturers=[{"label": "Alice"}, {"name_zh": "Bob", "name_en": None}], 

104 meetings=[{"classroom": None, "time": "bad", "time_codes": None}], 

105 programs=["Program A", "Program B"], 

106 ) 

107 assert Condition("lecturers.label", "Alice").check(course) 

108 assert Condition("lecturers.name_en", None).check(course) 

109 assert Condition("meetings.time_codes", None).check(course) 

110 assert Condition("programs", "B", True).check(course) 

111 assert not Condition("lecturers.label", None).check(course) 

112 assert not Condition("programs", "").check(course) 

113 empty = make_course() 

114 assert not Condition("lecturers.name_en", None).check(empty) 

115 assert not Condition("meetings.time_codes", None).check(empty) 

116 assert not Condition("programs", "A").check(empty) 

117 assert Condition("is_cancelled", False).check(empty) 

118 assert Condition("reserved_for_freshmen", 0).check(empty) 

119 

120 

121def test_independent_any_conditions_and_cached_predicate(): 

122 course = make_course(lecturers=[{"label": "Alice"}, {"name_zh": "Bob", "name_en": None}]) 

123 conditions = Conditions("lecturers.label", "Alice") & Conditions("lecturers.name_zh", "Bob") 

124 assert conditions.accept(course) 

125 predicate = conditions._predicate 

126 assert conditions.accept(course) 

127 assert conditions._predicate is predicate 

128 assert not hasattr(conditions, "course") 

129 assert (Conditions("id", "absent") | conditions).accept(course) 

130 

131 

132@pytest.mark.parametrize("body", [["and"], [True, "xor", True], [True, True], [42], [None]]) 

133def test_domain_invalid_conditions(body): 

134 conditions = Conditions(list_build_target=body) 

135 course = make_course() 

136 with pytest.raises((ValueError, TypeError)): 

137 conditions.accept(course) 

138 

139 

140def test_conditions_require_a_field_or_sequence(): 

141 with pytest.raises(ValueError): 

142 Conditions()