sahil-12kumar/IL_CMS_Tools
1
1"""Resolve TOC must report row 1 of a question's "Visible to students in this2exams" list, not the master taxonomy.3 4CMS shows that list on the question page, numbered, e.g. for ILQ-1836594:5 6 1. CBSE Grade 7 > Mathematics > Exponents and Powers > Laws of exponents7 2. CBSE Grade 8 > Mathematics > Power Play. > Laws of Exponents8 3. Grade 8-AP State Board > Mathematics > Exponents and Powers(Inactive) > ...9 10Rows are exam > subject > chapter > topic. The SME wants row 1 — the chapter a11student meets first — so the API's own order decides, NOT the question's stored12chapter_id, which frequently points at a later row. Note "(Inactive)" in row 313is part of the chapter NAME; every row in this panel is student-visible.14 15Both readers — the web app and the standalone extract_toc CLI — carry the same16resolution, so both are pinned here.17"""18import sys19from pathlib import Path20 21import pytest22 23sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'backend' / 'ai_tagger'))24 25MASTER = {'subjectName': 'Maths',26 'chapterName': 'Exponents (Master)',27 'topicName': 'Powers (Master)'}28 29 30def row(exam_id, exam, subject, chap_id, chapter, topic_id, topic):31 return {'examDetails': {'examId': exam_id, 'examName': exam},32 'subjectDetails': {'subjectId': 4, 'subjectName': subject},33 'chapterDetails': {'chapterId': chap_id, 'chapterName': chapter},34 'topicDetails': {'topicId': topic_id, 'topicName': topic}}35 36 37# The first rows of ILQ-1836594's list, in the order CMS numbers them.38PAYLOAD = {'data': [{39 'subtopicName': 'Laws of exponents',40 'subtopicMasterTocNavigation': MASTER,41 'subtopicExamTocNavigation': [42 row(40, 'CBSE Grade 7', 'Mathematics',43 700, 'Exponents and Powers', 800, 'Laws of exponents'),44 row(41, 'CBSE Grade 8', 'Mathematics',45 701, 'Power Play.', 801, 'Laws of Exponents'),46 row(42, 'Grade 8-AP State Board', 'Mathematics',47 702, 'Exponents and Powers(Inactive)', 802, 'Laws of Exponents'),48 row(43, 'Grade 7-AP State Board', 'Mathematics',49 703, 'Exponents and Powers(T)', 803,50 'Exponential Form and Laws of Exponents'),51 ]}]}52 53CHAIN_1 = 'CBSE Grade 7 > Mathematics > Exponents and Powers > Laws of exponents'54 55 56@pytest.fixture(scope='module')57def toc_mod():58 import extract_toc59 return extract_toc60 61 62@pytest.fixture(params=['app', 'cli'])63def mod(request, app_mod, toc_mod):64 """Both copies of the resolver — they must stay in step."""65 return app_mod if request.param == 'app' else toc_mod66 67 68def test_row_one_wins(mod):69 assert mod._exam_toc_names(PAYLOAD) == {70 'exam': 'CBSE Grade 7',71 'subject': 'Mathematics',72 'chapter': 'Exponents and Powers',73 'topic': 'Laws of exponents'}74 75 76def test_chain_is_printed_like_cms(mod):77 assert mod._toc_chain(mod._exam_toc_names(PAYLOAD)) == CHAIN_178 79 80def test_stored_chapter_id_does_not_reorder_the_list(mod, app_mod, monkeypatch):81 """The question's stored chapter_id points at row 3 here; row 1 must still82 be reported. This is the bug the SME hit twice."""83 monkeypatch.setattr(app_mod, '_comm_headers', lambda token, uid: {})84 monkeypatch.setattr(app_mod, '_comm_get', lambda *a, **kw: PAYLOAD)85 86 names = app_mod._resolve_toc_names('tok', 'uid', {87 'grade': [7], 'subject_id': [4],88 'chapter_id': [702], 'topic_id': [802], 'sub_topic_id': [900]})89 90 assert names['chapter'] == 'Exponents and Powers'91 assert names['exam'] == 'CBSE Grade 7'92 assert app_mod._toc_chain(names) == CHAIN_193 94 95def test_an_inactive_looking_name_is_not_filtered(mod):96 """"(Inactive)" lives in the chapter name, so a list whose row 1 carries it97 still reports that row — dropping it would invent a rule CMS doesn't have."""98 payload = {'data': [{'subtopicExamTocNavigation': [99 row(42, 'Grade 8-AP State Board', 'Mathematics',100 702, 'Exponents and Powers(Inactive)', 802, 'Laws of Exponents')]}]}101 assert mod._exam_toc_names(payload)['chapter'] == 'Exponents and Powers(Inactive)'102 103 104def test_subject_comes_from_the_row_not_the_master_tree(app_mod, monkeypatch):105 """Master says "Maths", row 1 says "Mathematics" — the row wins."""106 monkeypatch.setattr(app_mod, '_comm_headers', lambda token, uid: {})107 monkeypatch.setattr(app_mod, '_comm_get', lambda *a, **kw: PAYLOAD)108 109 names = app_mod._resolve_toc_names('tok', 'uid', {110 'grade': [7], 'subject_id': [4],111 'chapter_id': [700], 'topic_id': [800], 'sub_topic_id': [900]})112 assert names['subject'] == 'Mathematics'113 assert names['subtopic'] == 'Laws of exponents' # still the question's own114 115 116def test_chain_stops_at_the_topic(mod):117 """CMS's list has four levels; the subtopic is a column of its own, not a118 fifth step in the chain."""119 assert 'subtopic' not in mod._toc_chain(120 {'exam': 'CBSE Grade 7', 'subject': 'Mathematics',121 'chapter': 'Exponents and Powers', 'topic': 'Laws of exponents',122 'subtopic': 'Something Else'})123 assert mod._toc_chain(124 {'exam': 'CBSE Grade 7', 'subject': 'Mathematics',125 'chapter': 'Exponents and Powers', 'topic': 'Laws of exponents',126 'subtopic': 'Something Else'}) == CHAIN_1127 128 129def test_level_names_are_found_wherever_the_api_hangs_them(mod):130 """A row is anchored on its `chapterDetails` key — the one part of the shape131 production already relies on (push_tags._nav_entries). Everything else is132 found by name, so flat and snake_case keys resolve too."""133 payload = {'data': [{'subtopicExamTocNavigation': [134 {'exam_name': 'CBSE Grade 7',135 'subjectDetails': {'subject_name': 'Mathematics'},136 'chapterDetails': {'chapterName': 'Exponents and Powers'},137 'topicName': 'Laws of exponents'}]}]}138 assert mod._toc_chain(mod._exam_toc_names(payload)) == CHAIN_1139 140 141def test_a_row_never_borrows_a_sibling_rows_name(mod):142 """Rows sit in a list; the search descends dicts only, so a row missing a143 level reports it blank instead of stealing the next row's."""144 payload = {'data': [{'subtopicExamTocNavigation': [145 {'examDetails': {'examName': 'CBSE Grade 7'},146 'chapterDetails': {'chapterId': 700, 'chapterName': 'Exponents and Powers'}},147 row(41, 'CBSE Grade 8', 'Mathematics',148 701, 'Power Play.', 801, 'Laws of Exponents'),149 ]}]}150 out = mod._exam_toc_names(payload)151 assert out == {'exam': 'CBSE Grade 7', 'chapter': 'Exponents and Powers'}152 assert mod._toc_chain(out) == 'CBSE Grade 7 > Exponents and Powers'153 154 155def test_a_row_with_no_chapter_or_topic_is_skipped(mod):156 payload = {'data': [{'subtopicExamTocNavigation': [157 {'examDetails': {'examName': 'CBSE Grade 7'}, 'chapterDetails': {}},158 row(41, 'CBSE Grade 8', 'Mathematics',159 701, 'Power Play.', 801, 'Laws of Exponents'),160 ]}]}161 assert mod._exam_toc_names(payload)['chapter'] == 'Power Play.'162 163 164@pytest.mark.parametrize('payload', [165 {'data': []},166 {'data': [{'subtopicMasterTocNavigation': MASTER}]}, # master-only question167 {'data': [{'subtopicExamTocNavigation': None}]},168])169def test_no_rows_falls_back_to_master(mod, payload):170 assert mod._exam_toc_names(payload) == {}171 172 173def test_nav_cache_collapses_repeat_subtopics(app_mod, monkeypatch):174 """A pasted batch is usually one chapter, so the same subtopics repeat. The175 per-job cache must turn those into a single community call."""176 calls = []177 178 def fake_get(path, headers, params=None, json_body=None, method='GET'):179 calls.append((params or {}).get('nodeIds'))180 return PAYLOAD181 182 monkeypatch.setattr(app_mod, '_comm_headers', lambda token, uid: {})183 monkeypatch.setattr(app_mod, '_comm_get', fake_get)184 185 cache = {}186 for _ in range(20):187 names = app_mod._resolve_toc_names('tok', 'uid', {188 'grade': [7], 'subject_id': [4], 'chapter_id': [700],189 'topic_id': [800], 'sub_topic_id': [900]}, nav_cache=cache)190 assert names['chapter'] == 'Exponents and Powers' # cached rows resolve191 assert calls == [900], f'expected 1 call, got {len(calls)}'192 193 194def test_nav_cache_is_per_subtopic_not_global(app_mod, monkeypatch):195 """Different subtopics must still each be fetched — the cache keys on the196 subtopic, it doesn't just memoise the first answer."""197 calls = []198 199 def fake_get(path, headers, params=None, json_body=None, method='GET'):200 calls.append((params or {}).get('nodeIds'))201 return PAYLOAD202 203 monkeypatch.setattr(app_mod, '_comm_headers', lambda token, uid: {})204 monkeypatch.setattr(app_mod, '_comm_get', fake_get)205 206 cache = {}207 for stid in (900, 901, 900, 902, 901):208 app_mod._resolve_toc_names('tok', 'uid', {209 'grade': [7], 'subject_id': [4], 'sub_topic_id': [stid]},210 nav_cache=cache)211 assert calls == [900, 901, 902]212 213 214def test_workers_stay_in_a_sane_range(app_mod):215 assert 1 <= app_mod.RESOLVE_TOC_WORKERS <= 32216 217 218@pytest.mark.parametrize('raw, expected', [219 (None, 12), ('', 12), ('garbage', 12), # bad input keeps the default220 ('1', 1), ('24', 24),221 ('0', 1), ('-5', 1), ('999', 32), # clamped222])223def test_worker_env_override_is_clamped(app_mod, monkeypatch, raw, expected):224 if raw is None:225 monkeypatch.delenv('RESOLVE_TOC_WORKERS', raising=False)226 else:227 monkeypatch.setenv('RESOLVE_TOC_WORKERS', raw)228 assert app_mod._env_int('RESOLVE_TOC_WORKERS', 12, 1, 32) == expected229 230 231def test_master_names_survive_when_there_are_no_rows(app_mod, monkeypatch):232 payload = {'data': [{'subtopicName': 'Laws of exponents',233 'subtopicMasterTocNavigation': MASTER}]}234 monkeypatch.setattr(app_mod, '_comm_headers', lambda token, uid: {})235 monkeypatch.setattr(app_mod, '_comm_get', lambda *a, **kw: payload)236 237 names = app_mod._resolve_toc_names('tok', 'uid', {238 'grade': [7], 'subject_id': [4], 'chapter_id': [], 'topic_id': [],239 'sub_topic_id': [900]})240 241 assert names['chapter'] == 'Exponents (Master)'242 assert names['topic'] == 'Powers (Master)'243 assert names['exam'] == ''244 