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
« 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."""
3import pytest
4from conftest import course_payload, make_course
5from pydantic import ValidationError
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
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
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)
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])
60def test_snapshot_identity_and_semester_validation():
61 from data_api.data.nthudata import FetchFailure
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})
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)
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)
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)
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)
140def test_conditions_require_a_field_or_sequence():
141 with pytest.raises(ValueError):
142 Conditions()