/
githubmirror
/
oppia
Обзор
Документация
Войти
/
githubmirror
/
oppia
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
develop
core/domain/question_services_test.py
4 115 строк
152 KB
Gabriel Fuentes
Black formatter staging (#23456)
05 окт 2025, 06:15
Не верифицирован
05 окт 2025, 06:15
62ec95a
Код
Авторство
О чём код?
# coding: utf-8 # # Copyright 2018 The Oppia Authors. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS-IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Tests for core.domain.question_services.""" from __future__ import annotations import logging import re from core import feconf from core.domain import ( question_domain, question_fetchers, question_services, skill_domain, skill_services, state_domain, topic_domain, topic_fetchers, translation_domain, user_services, ) from core.platform import models from core.tests import test_utils from typing import Callable, Dict, List, Union MYPY = False if MYPY: # pragma: no cover from mypy_imports import question_models (question_models,) = models.Registry.import_models([models.Names.QUESTION]) class QuestionServicesUnitTest(test_utils.GenericTestBase): """Test the question services module.""" def setUp(self) -> None: """Before each individual test, create dummy user.""" super().setUp() self.signup(self.TOPIC_MANAGER_EMAIL, self.TOPIC_MANAGER_USERNAME) self.signup(self.NEW_USER_EMAIL, self.NEW_USER_USERNAME) self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME) self.signup(self.CURRICULUM_ADMIN_EMAIL, self.CURRICULUM_ADMIN_USERNAME) self.admin_id = self.get_user_id_from_email(self.CURRICULUM_ADMIN_EMAIL) self.topic_manager_id = self.get_user_id_from_email( self.TOPIC_MANAGER_EMAIL ) self.new_user_id = self.get_user_id_from_email(self.NEW_USER_EMAIL) self.editor_id = self.get_user_id_from_email(self.EDITOR_EMAIL) self.set_curriculum_admins([self.CURRICULUM_ADMIN_USERNAME]) self.admin = user_services.get_user_actions_info(self.admin_id) self.new_user = user_services.get_user_actions_info(self.new_user_id) self.editor = user_services.get_user_actions_info(self.editor_id) self.topic_id = topic_fetchers.get_new_topic_id() subtopic_1 = topic_domain.Subtopic.create_default_subtopic( 1, 'Subtopic Title 1', 'url-frag-one' ) subtopic_1.skill_ids = ['skill_id_1'] subtopic_1.url_fragment = 'sub-one-frag' self.save_new_topic( self.topic_id, self.admin_id, name='Name', description='Description', canonical_story_ids=[], additional_story_ids=[], uncategorized_skill_ids=[], subtopics=[subtopic_1], next_subtopic_id=2, ) self.set_topic_managers([self.TOPIC_MANAGER_USERNAME], self.topic_id) self.topic_manager = user_services.get_user_actions_info( self.topic_manager_id ) self.save_new_skill( 'skill_1', self.admin_id, description='Skill Description 1' ) self.save_new_skill( 'skill_2', self.admin_id, description='Skill Description 2' ) self.save_new_skill( 'skill_3', self.admin_id, description='Skill Description 3' ) self.question_id = question_services.get_new_question_id() self.content_id_generator = translation_domain.ContentIdGenerator() self.question = self.save_new_question( self.question_id, self.editor_id, self._create_valid_question_data('ABC', self.content_id_generator), ['skill_1'], self.content_id_generator.next_content_id_index, inapplicable_skill_misconception_ids=[ 'skillid12345-1', 'skillid12345-2', ], ) self.question_id_1 = question_services.get_new_question_id() self.content_id_generator_1 = translation_domain.ContentIdGenerator() self.question_1 = self.save_new_question( self.question_id_1, self.editor_id, self._create_valid_question_data( 'ABC', self.content_id_generator_1 ), ['skill_2'], self.content_id_generator_1.next_content_id_index, ) self.question_id_2 = question_services.get_new_question_id() self.content_id_generator_2 = translation_domain.ContentIdGenerator() self.question_2 = self.save_new_question( self.question_id_2, self.editor_id, self._create_valid_question_data( 'ABC', self.content_id_generator_2 ), ['skill_2'], self.content_id_generator_2.next_content_id_index, ) def test_get_question_by_id(self) -> None: question = question_services.get_question_by_id(self.question_id) self.assertEqual(question.id, self.question_id) question_with_none = question_services.get_question_by_id( 'question_id', strict=False ) self.assertIsNone(question_with_none) with self.assertRaisesRegex( Exception, 'Entity for class QuestionModel with id question_id not found', ): question_services.get_question_by_id('question_id') def test_get_questions_by_skill_ids_with_fetch_by_difficulty(self) -> None: question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_1, 'skill_2', 0.8 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_2, 'skill_2', 0.5 ) questions = question_services.get_questions_by_skill_ids( 2, ['skill_1', 'skill_2'], True ) sort_fn: Callable[[question_domain.Question], float] = ( lambda question: ( question.last_updated.timestamp() if question.last_updated else 0 ) ) questions.sort(key=sort_fn) self.assertEqual(len(questions), 2) self.assertEqual(questions[0].to_dict(), self.question.to_dict()) self.assertEqual(questions[1].to_dict(), self.question_2.to_dict()) def test_get_total_question_count_for_skill_ids(self) -> None: question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_1, 'skill_1', 0.8 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_2, 'skill_2', 0.5 ) question_count = ( question_services.get_total_question_count_for_skill_ids( ['skill_1'] ) ) self.assertEqual(question_count, 2) question_count = ( question_services.get_total_question_count_for_skill_ids( ['skill_2'] ) ) self.assertEqual(question_count, 1) question_count = ( question_services.get_total_question_count_for_skill_ids( ['skill_1', 'skill_2'] ) ) self.assertEqual(question_count, 3) question_count = ( question_services.get_total_question_count_for_skill_ids( ['skill_1', 'skill_1'] ) ) self.assertEqual(question_count, 2) question_count = ( question_services.get_total_question_count_for_skill_ids( ['skill_1', 'skill_1', 'skill_2'] ) ) self.assertEqual(question_count, 3) def test_update_question_skill_link_difficulty(self) -> None: question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) _, merged_question_skill_links = ( question_services.get_displayable_question_skill_link_details( 2, ['skill_1'], 0 ) ) self.assertEqual( merged_question_skill_links[0].skill_difficulties, [0.3] ) question_services.update_question_skill_link_difficulty( self.question_id, 'skill_1', 0.9 ) _, merged_question_skill_links = ( question_services.get_displayable_question_skill_link_details( 2, ['skill_1'], 0 ) ) self.assertEqual( merged_question_skill_links[0].skill_difficulties, [0.9] ) with self.assertRaisesRegex( Exception, 'The given question and skill are not linked.' ): question_services.update_question_skill_link_difficulty( self.question_id, 'skill_10', 0.9 ) def test_get_questions_by_skill_ids_without_fetch_by_difficulty( self, ) -> None: question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_1, 'skill_2', 0.8 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id_2, 'skill_2', 0.5 ) questions = question_services.get_questions_by_skill_ids( 4, ['skill_1', 'skill_2'], False ) sort_fn: Callable[[question_domain.Question], float] = ( lambda question: ( question.last_updated.timestamp() if question.last_updated else 0 ) ) questions.sort(key=sort_fn) self.assertEqual(len(questions), 3) self.assertEqual(questions[0].to_dict(), self.question.to_dict()) self.assertEqual(questions[1].to_dict(), self.question_1.to_dict()) self.assertEqual(questions[2].to_dict(), self.question_2.to_dict()) def test_get_questions_by_skill_ids_raise_error_with_high_question_count( self, ) -> None: with self.assertRaisesRegex( Exception, 'Question count is too high, please limit the question ' 'count to %d.' % feconf.MAX_QUESTIONS_FETCHABLE_AT_ONE_TIME, ): question_services.get_questions_by_skill_ids( 25, ['skill_1', 'skill_2'], False ) def test_create_multi_question_skill_links_for_question(self) -> None: content_id_generator = translation_domain.ContentIdGenerator() self.question = self.save_new_question( self.question_id, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) with self.assertRaisesRegex( Exception, 'Skill difficulties and skill ids should match. ' 'The lengths of the two lists are different.', ): question_services.link_multiple_skills_for_question( self.editor_id, self.question_id, ['skill_1', 'skill_2'], [0.5] ) question_services.link_multiple_skills_for_question( self.editor_id, self.question_id, ['skill_1', 'skill_2'], [0.5, 0.7] ) skill_ids = [ skill.id for skill in question_services.get_skills_linked_to_question( self.question_id ) ] self.assertItemsEqual(skill_ids, ['skill_1', 'skill_2']) def test_delete_question_skill_link(self) -> None: question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_2', 0.3 ) question_services.delete_question_skill_link( self.editor_id, self.question_id, 'skill_1' ) skill_ids = [ skill.id for skill in question_services.get_skills_linked_to_question( self.question_id ) ] self.assertItemsEqual(skill_ids, ['skill_2']) question_services.delete_question_skill_link( self.editor_id, self.question_id, 'skill_2' ) question = question_services.get_question_by_id( self.question_id, strict=False ) self.assertIsNone(question) def test_linking_same_skill_to_question_twice(self) -> None: question_id_2 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_2, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) skill_ids = [ skill.id for skill in question_services.get_skills_linked_to_question( question_id_2 ) ] self.assertEqual(len(skill_ids), 1) self.assertEqual(skill_ids[0], 'skill_1') question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.3 ) skill_ids = [ skill.id for skill in question_services.get_skills_linked_to_question( question_id_2 ) ] self.assertEqual(len(skill_ids), 1) self.assertEqual(skill_ids[0], 'skill_1') question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_2', 0.3 ) skill_ids = [ skill.id for skill in question_services.get_skills_linked_to_question( question_id_2 ) ] self.assertEqual(len(skill_ids), 2) self.assertItemsEqual(skill_ids, ['skill_1', 'skill_2']) def test_create_and_get_question_skill_link(self) -> None: question_id_2 = question_services.get_new_question_id() with self.assertRaisesRegex( Exception, re.escape( 'Entity for class QuestionModel with id %s not found' % (question_id_2) ), ): question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.5 ) content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_2, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) question_id_3 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_3, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_2'], content_id_generator.next_content_id_index, ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.5 ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_3', 0.8 ) question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, question_id_3, 'skill_2', 0.2 ) question_summaries_with_none, merged_question_skill_links = ( question_services.get_displayable_question_skill_link_details( 5, ['skill_1', 'skill_2', 'skill_3'], 0 ) ) with self.assertRaisesRegex( Exception, 'Querying linked question summaries for more than 3 ' 'skills at a time is not supported currently.', ): question_services.get_displayable_question_skill_link_details( 5, ['skill_1', 'skill_2', 'skill_3', 'skill_4'], 0 ) question_ids = [] for summary in question_summaries_with_none: # Ruling out the possibility of None for mypy type checking. assert summary is not None question_ids.append(summary.id) self.assertEqual(len(question_ids), 3) self.assertEqual(len(merged_question_skill_links), 3) self.assertItemsEqual( question_ids, [self.question_id, question_id_2, question_id_3] ) self.assertItemsEqual( question_ids, [ question_skill_link.question_id for question_skill_link in merged_question_skill_links ], ) # Make sure the correct skill description corresponds to respective # question summaries. for index, link_object in enumerate(merged_question_skill_links): if question_ids[index] == self.question_id: self.assertEqual( ['Skill Description 3', 'Skill Description 1'], link_object.skill_descriptions, ) self.assertEqual([0.8, 0.5], link_object.skill_difficulties) elif question_ids[index] == question_id_2: self.assertEqual( ['Skill Description 1'], link_object.skill_descriptions ) self.assertEqual([0.3], link_object.skill_difficulties) else: self.assertEqual( ['Skill Description 2'], link_object.skill_descriptions ) self.assertEqual([0.2], link_object.skill_difficulties) question_summaries_with_none, merged_question_skill_links = ( question_services.get_displayable_question_skill_link_details( 5, ['skill_1', 'skill_3'], 0 ) ) question_ids = [] for summary in question_summaries_with_none: # Ruling out the possibility of None for mypy type checking. assert summary is not None question_ids.append(summary.id) self.assertEqual(len(question_ids), 2) self.assertItemsEqual(question_ids, [self.question_id, question_id_2]) with self.assertRaisesRegex( Exception, 'The question with ID %s is already linked to skill skill_1' % (self.question_id), ): question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.3 ) def test_get_displayable_question_skill_link_details_with_no_skill_ids( self, ) -> None: question_id = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) question_services.create_new_question_skill_link( self.editor_id, question_id, 'skill_1', 0.5 ) question_summaries, merged_question_skill_links = ( question_services.get_displayable_question_skill_link_details( 2, [], 0 ) ) self.assertEqual(question_summaries, []) self.assertEqual(merged_question_skill_links, []) def test_get_question_skill_links_of_skill(self) -> None: # If the skill id doesnt exist at all, it returns an empty list. question_skill_links = ( question_services.get_question_skill_links_of_skill( 'non_existent_skill_id', 'Skill Description' ) ) self.assertEqual(len(question_skill_links), 0) # If the question ids dont exist for a skill, it returns an empty list. question_skill_links = ( question_services.get_question_skill_links_of_skill( 'skill_1', 'Skill Description 1' ) ) self.assertEqual(len(question_skill_links), 0) question_id_2 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_2, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) question_id_3 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_3, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_2'], content_id_generator.next_content_id_index, ) # Setting skill difficulty for self.question_id. question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.5 ) question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, question_id_3, 'skill_2', 0.1 ) # When question ids exist, it returns a list of questionskilllinks. question_skill_links = ( question_services.get_question_skill_links_of_skill( 'skill_1', 'Skill Description 1' ) ) self.assertEqual(len(question_skill_links), 2) self.assertTrue( isinstance( question_skill_links[0], question_domain.QuestionSkillLink ) ) question_ids = [ question_skill.question_id for question_skill in question_skill_links ] self.assertItemsEqual(question_ids, [self.question_id, question_id_2]) for question_skill in question_skill_links: if question_skill.question_id == self.question_id: self.assertEqual(question_skill.skill_difficulty, 0.5) def test_get_question_summaries_by_ids(self) -> None: question_summaries = question_services.get_question_summaries_by_ids( [self.question_id, 'invalid_question_id'] ) # Ruling out the possibility of None for mypy type checking. assert question_summaries[0] is not None self.assertEqual(len(question_summaries), 2) self.assertEqual(question_summaries[0].id, self.question_id) self.assertEqual( question_summaries[0].question_content, feconf.DEFAULT_STATE_CONTENT_STR, ) self.assertIsNone(question_summaries[1]) def test_delete_question(self) -> None: question_summary_model = question_models.QuestionSummaryModel.get( self.question_id ) self.assertFalse(question_summary_model is None) question_services.delete_question(self.editor_id, self.question_id) with self.assertRaisesRegex( Exception, ( 'Entity for class QuestionModel with id %s not found' % (self.question_id) ), ): question_models.QuestionModel.get(self.question_id) with self.assertRaisesRegex( Exception, ( 'Entity for class QuestionSummaryModel with id %s not found' % (self.question_id) ), ): question_models.QuestionSummaryModel.get(self.question_id) def test_delete_question_marked_deleted(self) -> None: question_models.QuestionModel.delete_multi( [self.question_id], self.editor_id, feconf.COMMIT_MESSAGE_QUESTION_DELETED, force_deletion=False, ) question_model = question_models.QuestionModel.get_by_id( self.question_id ) self.assertTrue(question_model.deleted) question_services.delete_question( self.editor_id, self.question_id, force_deletion=True ) question_model = question_models.QuestionModel.get_by_id( self.question_id ) self.assertEqual(question_model, None) self.assertEqual( question_models.QuestionSummaryModel.get( self.question_id, strict=False ), None, ) def test_delete_question_model_with_deleted_summary_model(self) -> None: question_summary_model = question_models.QuestionSummaryModel.get( self.question_id ) question_summary_model.delete() question_summary_model_with_none = ( question_models.QuestionSummaryModel.get( self.question_id, strict=False ) ) self.assertIsNone(question_summary_model_with_none) question_services.delete_question( self.editor_id, self.question_id, force_deletion=True ) question_model = question_models.QuestionModel.get_by_id( self.question_id ) self.assertEqual(question_model, None) self.assertEqual( question_models.QuestionSummaryModel.get( self.question_id, strict=False ), None, ) def test_update_question(self) -> None: new_question_data = self._create_valid_question_data( 'DEF', self.content_id_generator ) change_list = [ question_domain.QuestionChange( { 'cmd': 'update_question_property', 'property_name': 'next_content_id_index', 'old_value': 0, 'new_value': self.content_id_generator.next_content_id_index, } ), question_domain.QuestionChange( { 'cmd': 'update_question_property', 'property_name': 'question_state_data', 'new_value': new_question_data.to_dict(), 'old_value': self.question.question_state_data.to_dict(), } ), ] question_services.update_question( self.editor_id, self.question_id, change_list, 'updated question data', ) question = question_services.get_question_by_id(self.question_id) self.assertEqual( question.question_state_data.to_dict(), new_question_data.to_dict() ) self.assertEqual(question.version, 2) def test_cannot_update_question_with_no_commit_message(self) -> None: new_question_data = self._create_valid_question_data( 'DEF', self.content_id_generator ) change_list = [ question_domain.QuestionChange( { 'cmd': 'update_question_property', 'property_name': 'question_state_data', 'new_value': new_question_data.to_dict(), 'old_value': self.question.question_state_data.to_dict(), } ), question_domain.QuestionChange( { 'cmd': 'update_question_property', 'property_name': 'next_content_id_index', 'old_value': 0, 'new_value': self.content_id_generator.next_content_id_index, } ), ] # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally test # wrong inputs that we can normally catch by typing. with self.assertRaisesRegex( Exception, 'Expected a commit message, received none.' ): question_services.update_question( self.editor_id, self.question_id, change_list, None # type: ignore[arg-type] ) def test_cannot_update_question_with_no_change_list(self) -> None: with self.assertRaisesRegex( Exception, 'Unexpected error: received an invalid change list when trying to ' 'save question', ): question_services.update_question( self.editor_id, self.question_id, [], 'updated question data' ) def test_update_question_language_code(self) -> None: self.assertEqual(self.question.language_code, 'en') change_dict = { 'cmd': 'update_question_property', 'property_name': 'language_code', 'new_value': 'bn', 'old_value': 'en', } change_list = [question_domain.QuestionChange(change_dict)] question_services.update_question( self.editor_id, self.question_id, change_list, 'updated question language code', ) question = question_services.get_question_by_id(self.question_id) self.assertEqual(question.language_code, 'bn') self.assertEqual(question.version, 2) def test_update_inapplicable_skill_misconception_ids(self) -> None: self.assertEqual( self.question.inapplicable_skill_misconception_ids, ['skillid12345-1', 'skillid12345-2'], ) change_dict: Dict[str, Union[str, List[str]]] = { 'cmd': 'update_question_property', 'property_name': 'inapplicable_skill_misconception_ids', 'new_value': ['skillid12345-1'], 'old_value': [], } change_list = [question_domain.QuestionChange(change_dict)] question_services.update_question( self.editor_id, self.question_id, change_list, 'updated inapplicable_skill_misconception_ids', ) question = question_services.get_question_by_id(self.question_id) self.assertEqual( question.inapplicable_skill_misconception_ids, ['skillid12345-1'] ) self.assertEqual(question.version, 2) def test_cannot_update_question_with_invalid_change_list(self) -> None: observed_log_messages = [] def _mock_logging_function(msg: str, *args: str) -> None: """Mocks logging.error().""" observed_log_messages.append(msg % args) logging_swap = self.swap(logging, 'error', _mock_logging_function) assert_raises_context_manager = self.assertRaisesRegex( Exception, '\'str\' object has no attribute \'cmd\'' ) # TODO(#13059): Here we use MyPy ignore because after we fully type # the codebase we plan to get rid of the tests that intentionally test # wrong inputs that we can normally catch by typing. with logging_swap, assert_raises_context_manager: question_services.update_question( self.editor_id, self.question_id, 'invalid_change_list', # type: ignore[arg-type] 'updated question language code', ) self.assertEqual(len(observed_log_messages), 1) self.assertRegex( observed_log_messages[0], 'object has no attribute \'cmd\' %s ' 'invalid_change_list' % self.question_id, ) def test_cannot_update_question_with_mismatch_of_versions(self) -> None: changelist = [ question_domain.QuestionChange( { 'cmd': 'update_question_property', 'property_name': 'language_code', 'new_value': 'bn', 'old_value': 'en', } ) ] with self.assertRaisesRegex( Exception, 'Trying to update version 2 of question from version 1, ' 'which is too old. Please reload the page and try again.', ): question_services.update_question( self.editor_id, self.question_id_2, changelist, 'change language_code', 2, ) question_model = question_models.QuestionModel.get(self.question_id_2) question_model.version = 100 with self.assertRaisesRegex( Exception, 'Unexpected error: trying to update version 1 of question ' 'from version 100. Please reload the page and try again.', ): question_services.update_question( self.editor_id, self.question_id_2, changelist, 'change language_code', 1, ) def test_replace_skill_id_for_all_questions(self) -> None: question_id_2 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_2, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) question_id_3 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_3, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_2'], content_id_generator.next_content_id_index, ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.5 ) question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, question_id_3, 'skill_2', 0.9 ) question_skill_links = ( question_services.get_question_skill_links_of_skill( 'skill_1', 'Skill Description 1' ) ) self.assertEqual(len(question_skill_links), 2) question_ids = [ question_skill.question_id for question_skill in question_skill_links ] self.assertItemsEqual(question_ids, [self.question_id, question_id_2]) for question_skill in question_skill_links: if question_skill.question_id == self.question_id: self.assertEqual(question_skill.skill_difficulty, 0.5) question_services.replace_skill_id_for_all_questions( 'skill_1', 'Description 1', 'skill_3' ) question_skill_links = ( question_services.get_question_skill_links_of_skill( 'skill_1', 'Description 1' ) ) self.assertEqual(len(question_skill_links), 0) question_skill_links = ( question_services.get_question_skill_links_of_skill( 'skill_3', 'Skill Description 3' ) ) question_ids = [ question_skill.question_id for question_skill in question_skill_links ] self.assertItemsEqual(question_ids, [self.question_id, question_id_2]) for question_skill in question_skill_links: if question_skill.question_id == self.question_id: self.assertEqual(question_skill.skill_difficulty, 0.5) questions = question_fetchers.get_questions_by_ids( [self.question_id, question_id_2, question_id_3] ) for question in questions: # Ruling out the possibility of None for mypy type checking. assert question is not None if question.id in ([self.question_id, question_id_2]): self.assertItemsEqual(question.linked_skill_ids, ['skill_3']) else: self.assertItemsEqual(question.linked_skill_ids, ['skill_2']) def test_compute_summary_of_question(self) -> None: question = question_services.get_question_by_id(self.question_id) question_summary = question_services.compute_summary_of_question( question ) self.assertEqual(question_summary.id, self.question_id) self.assertEqual( question_summary.question_content, feconf.DEFAULT_STATE_CONTENT_STR ) def test_raises_error_while_computing_summary_if_interaction_id_is_none( self, ) -> None: question = question_services.get_question_by_id(self.question_id) question.question_state_data.interaction.id = None with self.assertRaisesRegex( Exception, 'No interaction_id found for the given question.' ): question_services.compute_summary_of_question(question) def test_raises_error_when_the_question_provided_with_no_created_on_data( self, ) -> None: question = question_services.get_question_by_id(self.question_id) question.created_on = None with self.assertRaisesRegex( Exception, 'No data available for when the question was last_updated', ): question_services.compute_summary_of_question(question) def test_get_skills_of_question(self) -> None: # If the question id doesnt exist at all, it returns an empty list. with self.assertRaisesRegex( Exception, 'Entity for class QuestionModel with id ' 'non_existent_question_id not found', ): question_services.get_skills_linked_to_question( 'non_existent_question_id' ) question_id_2 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_2, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_1'], content_id_generator.next_content_id_index, ) question_id_3 = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() self.save_new_question( question_id_3, self.editor_id, self._create_valid_question_data('ABC', content_id_generator), ['skill_2'], content_id_generator.next_content_id_index, ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skill_1', 0.5 ) question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_1', 0.3 ) question_services.create_new_question_skill_link( self.editor_id, question_id_2, 'skill_2', 0.0 ) question_services.create_new_question_skill_link( self.editor_id, question_id_3, 'skill_2', 0.1 ) skills = question_services.get_skills_linked_to_question(question_id_2) self.assertTrue(isinstance(skills[0], skill_domain.Skill)) self.assertEqual(len(skills), 2) skill_ids = [skill.id for skill in skills] self.assertItemsEqual(skill_ids, ['skill_1', 'skill_2']) def test_get_interaction_id_for_question(self) -> None: self.assertEqual( question_services.get_interaction_id_for_question(self.question_id), 'TextInput', ) with self.assertRaisesRegex(Exception, 'No questions exists with'): question_services.get_interaction_id_for_question('fake_q_id') def test_untag_deleted_misconceptions_on_no_change_to_skill(self) -> None: misconceptions = [ skill_domain.Misconception( 0, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', True, ), skill_domain.Misconception( 1, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', True, ), skill_domain.Misconception( 2, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), skill_domain.Misconception( 3, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), skill_domain.Misconception( 4, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), ] self.save_new_skill( 'skillid12345', self.admin_id, description='Skill with misconceptions', misconceptions=misconceptions, ) self.question_id = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() question_state_data = self._create_valid_question_data( 'state name', content_id_generator ) feedback_content_ids = [ content_id_generator.generate( translation_domain.ContentType.FEEDBACK ) for _ in range(3) ] rule_content_ids = [ content_id_generator.generate( translation_domain.ContentType.RULE, extra_prefix='input' ) for _ in range(3) ] question_state_data.interaction.answer_groups = [ state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[0], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[0], 'normalizedStrSet': ['Test0'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-0', } ), state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[1], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[1], 'normalizedStrSet': ['Test1'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-1', } ), state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[2], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[2], 'normalizedStrSet': ['Test2'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-2', } ), ] inapplicable_skill_misconception_ids = [ 'skillid12345-3', 'skillid12345-4', ] self.question = self.save_new_question( self.question_id, self.editor_id, question_state_data, ['skillid12345'], content_id_generator.next_content_id_index, inapplicable_skill_misconception_ids=( inapplicable_skill_misconception_ids ), ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skillid12345', 0.5 ) answer_groups = ( self.question.question_state_data.interaction.answer_groups ) actual_misconception_ids = [ answer_group.to_dict()['tagged_skill_misconception_id'] for answer_group in answer_groups if answer_group.to_dict()['tagged_skill_misconception_id'] ] expected_misconception_ids = [ 'skillid12345-0', 'skillid12345-1', 'skillid12345-2', ] self.assertEqual( self.question.inapplicable_skill_misconception_ids, inapplicable_skill_misconception_ids, ) self.assertEqual(actual_misconception_ids, expected_misconception_ids) # Try to untag deleted skill misconceptions when there are no deleted # misconceptions. question_services.untag_deleted_misconceptions( self.editor_id, 'skillid12345', 'Skill with misconceptions', [] ) # No change when skill misconception ids exist. updated_question = question_services.get_question_by_id( self.question_id ) self.assertEqual( updated_question.inapplicable_skill_misconception_ids, inapplicable_skill_misconception_ids, ) self.assertEqual(actual_misconception_ids, expected_misconception_ids) def test_untag_deleted_misconceptions_correctly_on_updating_skill( self, ) -> None: misconceptions = [ skill_domain.Misconception( 0, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', True, ), skill_domain.Misconception( 1, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', True, ), skill_domain.Misconception( 2, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), skill_domain.Misconception( 3, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), skill_domain.Misconception( 4, 'misconception-name', '<p>description</p>', '<p>default_feedback</p>', False, ), ] self.save_new_skill( 'skillid12345', self.admin_id, description='Skill with misconceptions', misconceptions=misconceptions, ) self.question_id = question_services.get_new_question_id() content_id_generator = translation_domain.ContentIdGenerator() question_state_data = self._create_valid_question_data( 'state name', content_id_generator ) feedback_content_ids = [ content_id_generator.generate( translation_domain.ContentType.FEEDBACK ) for _ in range(3) ] rule_content_ids = [ content_id_generator.generate( translation_domain.ContentType.RULE, extra_prefix='input' ) for _ in range(3) ] question_state_data.interaction.answer_groups = [ state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[0], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[0], 'normalizedStrSet': ['Test0'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-0', } ), state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[1], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[1], 'normalizedStrSet': ['Test1'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-1', } ), state_domain.AnswerGroup.from_dict( { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': feedback_content_ids[2], 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': { 'contentId': rule_content_ids[2], 'normalizedStrSet': ['Test2'], } }, 'rule_type': 'Contains', } ], 'training_data': [], 'tagged_skill_misconception_id': 'skillid12345-2', } ), ] inapplicable_skill_misconception_ids = [ 'skillid12345-3', 'skillid12345-4', ] self.question = self.save_new_question( self.question_id, self.editor_id, question_state_data, ['skillid12345'], content_id_generator.next_content_id_index, inapplicable_skill_misconception_ids=( inapplicable_skill_misconception_ids ), ) question_services.create_new_question_skill_link( self.editor_id, self.question_id, 'skillid12345', 0.5 ) answer_groups = ( self.question.question_state_data.interaction.answer_groups ) actual_misconception_ids = [ answer_group.to_dict()['tagged_skill_misconception_id'] for answer_group in answer_groups if answer_group.to_dict()['tagged_skill_misconception_id'] ] expected_misconception_ids = [ 'skillid12345-0', 'skillid12345-1', 'skillid12345-2', ] self.assertEqual( self.question.inapplicable_skill_misconception_ids, inapplicable_skill_misconception_ids, ) self.assertEqual(actual_misconception_ids, expected_misconception_ids) # Delete few misconceptions. change_list = [ skill_domain.SkillChange( { 'cmd': skill_domain.CMD_DELETE_SKILL_MISCONCEPTION, 'misconception_id': 0, } ), skill_domain.SkillChange( { 'cmd': skill_domain.CMD_DELETE_SKILL_MISCONCEPTION, 'misconception_id': 2, } ), skill_domain.SkillChange( { 'cmd': skill_domain.CMD_DELETE_SKILL_MISCONCEPTION, 'misconception_id': 4, } ), ] skill_services.update_skill( self.editor_id, 'skillid12345', change_list, 'Delete misconceptions.', ) self.process_and_flush_pending_tasks() updated_question = question_services.get_question_by_id( self.question_id ) updated_answer_groups = ( updated_question.question_state_data.interaction.answer_groups ) actual_misconception_ids = [ answer_group.to_dict()['tagged_skill_misconception_id'] for answer_group in updated_answer_groups if answer_group.to_dict()['tagged_skill_misconception_id'] ] expected_misconception_ids = ['skillid12345-1'] actual_inapplicable_skill_misconception_ids = ( updated_question.inapplicable_skill_misconception_ids ) expected_inapplicable_skill_misconception_ids = ['skillid12345-3'] self.assertEqual( actual_inapplicable_skill_misconception_ids, expected_inapplicable_skill_misconception_ids, ) self.assertEqual(actual_misconception_ids, expected_misconception_ids) def test_populate_question_model_fields(self) -> None: model = question_models.QuestionModel( id=self.question_id, question_state_data={}, language_code='en', linked_skill_ids=['skill_id'], question_state_data_schema_version=( feconf.CURRENT_STATE_SCHEMA_VERSION ), ) question = question_services.get_question_by_id(self.question_id) populated_model = question_services.populate_question_model_fields( model, question ) self.assertEqual( populated_model.question_state_data, question.question_state_data.to_dict(), ) self.assertEqual( populated_model.question_state_data_schema_version, question.question_state_data_schema_version, ) self.assertEqual( populated_model.next_content_id_index, question.next_content_id_index, ) self.assertEqual(populated_model.language_code, question.language_code) self.assertEqual( populated_model.linked_skill_ids, question.linked_skill_ids ) self.assertEqual( populated_model.inapplicable_skill_misconception_ids, question.inapplicable_skill_misconception_ids, ) def test_populate_question_summary_model_fields(self) -> None: question = question_services.get_question_by_id(self.question_id) question_summary = question_services.compute_summary_of_question( question ) question_services.save_question_summary(question_summary) summary_model = question_models.QuestionSummaryModel.get( self.question_id ) populated_model = ( question_services.populate_question_summary_model_fields( summary_model, question_summary ) ) self.assertEqual( populated_model.question_model_last_updated, question_summary.last_updated, ) self.assertEqual( populated_model.question_model_created_on, question_summary.created_on, ) self.assertEqual( populated_model.question_content, question_summary.question_content ) self.assertEqual(populated_model.version, question_summary.version) self.assertEqual( populated_model.interaction_id, question_summary.interaction_id ) self.assertEqual( populated_model.misconception_ids, question_summary.misconception_ids, ) def test_populate_question_summary_model_fields_with_no_input_model( self, ) -> None: question = question_services.get_question_by_id(self.question_id) question_summary = question_services.compute_summary_of_question( question ) question_services.save_question_summary(question_summary) # Here we use MyPy ignore because we need to test # populate_question_summary_model_fields when the there is no # input QuestionSummaryModel. populated_model = question_services.populate_question_summary_model_fields( None, question_summary # type: ignore[arg-type] ) self.assertEqual( populated_model.question_model_last_updated, question_summary.last_updated, ) self.assertEqual( populated_model.question_model_created_on, question_summary.created_on, ) self.assertEqual( populated_model.question_content, question_summary.question_content ) self.assertEqual(populated_model.version, question_summary.version) self.assertEqual( populated_model.interaction_id, question_summary.interaction_id ) self.assertEqual( populated_model.misconception_ids, question_summary.misconception_ids, ) class QuestionMigrationTests(test_utils.GenericTestBase): def test_migrate_question_state_from_v29_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': 'Test'}, 'rule_type': 'Contains'}], 'training_data': [], 'tagged_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': { 'correct_answer': 'This is the correct answer', 'answer_is_exclusive': False, 'explanation': { 'content_id': 'explanation_1', 'html': 'Solution explanation', }, }, 'id': 'TextInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=29, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual(answer_groups[0].tagged_skill_misconception_id, None) def test_migrate_question_state_from_v30_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': 'Test'}, 'rule_type': 'Contains'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': { 'en': { 'filename': 'test.mp3', 'file_size_bytes': 100, 'needs_update': False, } }, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': { 'correct_answer': 'This is the correct answer', 'answer_is_exclusive': False, 'explanation': { 'content_id': 'explanation_1', 'html': 'Solution explanation', }, }, 'id': 'TextInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=30, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) def test_migrate_question_state_from_v31_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ {'inputs': {'x': ['A', 'B', 'C']}, 'rule_type': 'HasElementsIn'} ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'SetInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=31, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args # Ruling out the possibility of any other type for mypy type checking. assert isinstance( cust_args['buttonText'].value, state_domain.SubtitledUnicode ) self.assertEqual(cust_args['buttonText'].value.unicode_str, 'Add item') def test_migrate_question_state_from_v32_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['A']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {'choices': {'value': []}}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'MultipleChoiceInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=32, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args self.assertEqual(cust_args['choices'].value, []) self.assertEqual(cust_args['showChoicesInShuffledOrder'].value, True) def test_migrate_question_state_from_v33_to_latest(self) -> None: feedback_html_content = ( '<p>Value</p><oppia-noninteractive-math raw_latex-with-value="&a' 'mp;quot;+,-,-,+&quot;" svg_filename-with-value="&a' 'mp;quot;abc.svg&quot;"></oppia-noninteractive-math>' ) answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': feedback_html_content, }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['A']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'choices': {'value': ''}, 'showChoicesInShuffledOrder': {'value': True}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'MultipleChoiceInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } expected_feeedback_html_content = ( '<p>Value</p><oppia-noninteractive-math math_content-with-value=' '"{&quot;raw_latex&quot;: &quot;+,-,-,+&quot;, &' 'amp;quot;svg_filename&quot;: &quot;abc.svg&quot;}">' '</oppia-noninteractive-math>' ) question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=33, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) migrated_answer_group = ( question.question_state_data.interaction.answer_groups[0] ) self.assertEqual( migrated_answer_group.outcome.feedback.html, expected_feeedback_html_content, ) def test_migrate_question_state_from_v34_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': 'x+y'}, 'rule_type': 'IsMathematicallyEquivalentTo', }, { 'inputs': {'x': 'x=y'}, 'rule_type': 'IsMathematicallyEquivalentTo', }, ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': { 'correct_answer': {'ascii': 'x=y', 'latex': 'x=y'}, 'answer_is_exclusive': False, 'explanation': { 'html': 'Solution explanation', 'content_id': 'explanation_1', }, }, 'id': 'MathExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=34, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual( question.question_state_data.interaction.id, 'MathEquationInput' ) self.assertEqual(len(answer_groups[0].rule_specs), 1) self.assertEqual( answer_groups[0].rule_specs[0].rule_type, 'MatchesExactlyWith' ) self.assertEqual( answer_groups[0].rule_specs[0].inputs, {'x': 'x=y', 'y': 'both'} ) answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': 'x+y'}, 'rule_type': 'IsMathematicallyEquivalentTo', }, { 'inputs': {'x': '1.2 + 3'}, 'rule_type': 'IsMathematicallyEquivalentTo', }, ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': { 'correct_answer': {'ascii': 'x+y', 'latex': 'x+y'}, 'answer_is_exclusive': False, 'explanation': { 'html': 'Solution explanation', 'content_id': 'explanation_1', }, }, 'id': 'MathExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=34, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual( question.question_state_data.interaction.id, 'AlgebraicExpressionInput', ) self.assertEqual(len(answer_groups[0].rule_specs), 2) self.assertEqual( answer_groups[0].rule_specs[0].rule_type, 'MatchesExactlyWith' ) self.assertEqual(answer_groups[0].rule_specs[0].inputs, {'x': 'x+y'}) answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': '1,2 + 3'}, 'rule_type': 'IsMathematicallyEquivalentTo', } ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': { 'correct_answer': {'ascii': '1.2 + 3', 'latex': '1.2 + 3'}, 'answer_is_exclusive': False, 'explanation': { 'html': 'Solution explanation', 'content_id': 'explanation_1', }, }, 'id': 'MathExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=34, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual( question.question_state_data.interaction.id, 'NumericExpressionInput', ) self.assertEqual(len(answer_groups[0].rule_specs), 1) self.assertEqual( answer_groups[0].rule_specs[0].rule_type, 'MatchesExactlyWith' ) self.assertEqual( answer_groups[0].rule_specs[0].inputs, {'x': '1.2 + 3'} ) answer_groups_list = [ { 'outcome': { 'dest': 'Introduction', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': 'x=y'}, 'rule_type': 'IsMathematicallyEquivalentTo', } ], 'training_data': [], 'tagged_skill_misconception_id': None, }, { 'outcome': { 'dest': 'Introduction', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_2', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': '1.2 + 3'}, 'rule_type': 'IsMathematicallyEquivalentTo', } ], 'training_data': [], 'tagged_skill_misconception_id': None, }, ] question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'feedback_1': {}, 'feedback_2': {}, 'feedback_3': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'feedback_1': {}, 'feedback_2': {}, 'feedback_3': {}, } }, 'interaction': { 'answer_groups': answer_groups_list, 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': 'Introduction', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_3', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'MathExpressionInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=34, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual( question.question_state_data.interaction.id, 'MathEquationInput' ) self.assertEqual(len(answer_groups), 1) self.assertEqual( answer_groups[0].rule_specs[0].rule_type, 'MatchesExactlyWith' ) self.assertEqual( answer_groups[0].rule_specs[0].inputs, {'x': 'x=y', 'y': 'both'} ) def test_migrate_question_state_from_v35_to_latest(self) -> None: # Test restructuring of written_translations. question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'explanation': { 'en': {'html': '<p>test</p>', 'needs_update': True} } } }, 'interaction': { 'answer_groups': [], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': None, }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=35, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) # Test migration of PencilCodeEditor customization argument from # intial_code to intialCode. question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [], 'confirmed_unclassified_answers': [], 'customization_args': {'initial_code': {'value': 'code'}}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'PencilCodeEditor', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=35, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) migrated_ca = question.question_state_data.to_dict()['interaction'][ 'customization_args' ] self.assertEqual(migrated_ca, {'initialCode': {'value': 'code'}}) # Test population of default value of SubtitledHtml list. question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'MultipleChoiceInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=35, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) migrated_ca = question.question_state_data.to_dict()['interaction'][ 'customization_args' ] self.assertEqual( migrated_ca, { 'choices': { 'value': [{'content_id': 'ca_choices_2', 'html': ''}] }, 'showChoicesInShuffledOrder': {'value': True}, }, ) # Test migration of html list to SubtitledHtml list. question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': {'translations_mapping': {}}, 'interaction': { 'answer_groups': [], 'confirmed_unclassified_answers': [], 'customization_args': { 'choices': {'value': ['one', 'two', 'three']} }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'MultipleChoiceInput', }, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=35, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) migrated_ca = question.question_state_data.to_dict()['interaction'][ 'customization_args' ] self.assertEqual( migrated_ca, { 'choices': { 'value': [ {'content_id': 'ca_choices_2', 'html': 'one'}, {'content_id': 'ca_choices_3', 'html': 'two'}, {'content_id': 'ca_choices_4', 'html': 'three'}, ] }, 'showChoicesInShuffledOrder': {'value': True}, }, ) def test_migrate_question_state_from_v36_to_latest(self) -> None: # Test restructuring of written_translations. question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, 'ca_placeholder_0': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, 'ca_placeholder_0': {}, } }, 'interaction': { 'answer_groups': [ { 'outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Ans2er', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': 'test'}, 'rule_type': 'CaseSensitiveEquals', } ], 'tagged_skill_misconception_id': None, 'training_data': [], } ], 'confirmed_unclassified_answers': [], 'customization_args': { 'placeholder': { 'value': { 'content_id': 'ca_placeholder_0', 'unicode_str': '', } }, 'rows': {'value': 1}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'TextInput', }, 'next_content_id_index': 2, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=36, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) migrated_rule_spec = ( question.question_state_data.interaction.answer_groups[0] .rule_specs[0] .to_dict() ) self.assertEqual( migrated_rule_spec, { 'inputs': { 'x': { 'contentId': 'rule_input_3', 'normalizedStrSet': ['test'], } }, 'rule_type': 'Equals', }, ) def test_migrate_question_state_from_v37_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': '((x)^(2))/(2.5)-(alpha)/(beta)'}, 'rule_type': 'MatchesExactlyWith', } ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'AlgebraicExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=37, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args self.assertEqual(cust_args['allowedVariables'].value, ['x', 'α', 'β']) def test_migrate_question_state_from_v38_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ {'inputs': {'x': '1/2'}, 'rule_type': 'MatchesExactlyWith'} ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': {}, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'NumericExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=38, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args # Ruling out the possibility of any other type for mypy type checking. assert isinstance( cust_args['placeholder'].value, state_domain.SubtitledUnicode ) self.assertEqual( cust_args['placeholder'].value.unicode_str, 'Type an expression here, using only numbers.', ) def test_migrate_question_state_with_text_input_from_v40_to_latest( self, ) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['Test']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'placeholder': { 'value': { 'content_id': 'ca_placeholder_0', 'unicode_str': '', } }, 'rows': {'value': 1}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'TextInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=40, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_group_object = ( question.question_state_data.interaction.answer_groups[0] ) rule_spec = answer_group_object.rule_specs[0] self.assertEqual( rule_spec.inputs['x'], {'contentId': 'rule_input_3', 'normalizedStrSet': ['Test']}, ) def test_migrate_question_state_with_set_input_from_v40_to_latest( self, ) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['Test']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_buttonText_0': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'ca_buttonText_0': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'buttonText': { 'value': { 'content_id': 'ca_buttonText_0', 'unicode_str': '', } }, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'SetInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=40, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_group_object = ( question.question_state_data.interaction.answer_groups[0] ) rule_spec = answer_group_object.rule_specs[0] self.assertEqual( rule_spec.inputs['x'], {'contentId': 'rule_input_3', 'unicodeStrSet': ['Test']}, ) def test_migrate_question_state_from_v41_with_item_selection_input_interaction_to_latest( # pylint: disable=line-too-long self, ) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': {'x': ['<p>Choice 1</p>', '<p>Choice 2</p>']}, 'rule_type': 'Equals', } ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_choices_2': {}, 'ca_choices_3': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_choices_2': {}, 'ca_choices_3': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'choices': { 'value': [ { 'content_id': 'ca_choices_2', 'html': '<p>Choice 1</p>', }, { 'content_id': 'ca_choices_3', 'html': '<p>Choice 2</p>', }, ] }, 'maxAllowableSelectionCount': {'value': 2}, 'minAllowableSelectionCount': {'value': 1}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': { 'answer_is_exclusive': True, 'correct_answer': ['<p>Choice 1</p>'], 'explanation': { 'content_id': 'explanation_1', 'html': 'This is <i>solution</i> for state1', }, }, 'id': 'ItemSelectionInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=41, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_group_object = ( question.question_state_data.interaction.answer_groups[0] ) solution = question.question_state_data.interaction.solution # Ruling out the possibility of None for mypy type checking. assert solution is not None rule_spec = answer_group_object.rule_specs[0] self.assertEqual( rule_spec.inputs['x'], ['ca_choices_4', 'ca_choices_5'] ) self.assertEqual(solution.correct_answer, ['ca_choices_4']) def test_migrate_question_state_from_v41_with_drag_and_drop_sort_input_interaction_to_latest( # pylint: disable=line-too-long self, ) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ { 'inputs': { 'x': [['<p>Choice 1</p>', '<p>Choice 2</p>', 'invalid']] }, 'rule_type': 'IsEqualToOrdering', }, { 'inputs': {'x': [['<p>Choice 1</p>']]}, 'rule_type': 'IsEqualToOrderingWithOneItemAtIncorrectPosition', }, { 'inputs': {'x': '<p>Choice 1</p>', 'y': 1}, 'rule_type': 'HasElementXAtPositionY', }, { 'inputs': {'x': '<p>Choice 1</p>', 'y': '<p>Choice 2</p>'}, 'rule_type': 'HasElementXBeforeElementY', }, ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_choices_2': {}, 'ca_choices_3': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_choices_2': {}, 'ca_choices_3': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'allowMultipleItemsInSamePosition': {'value': True}, 'choices': { 'value': [ { 'content_id': 'ca_choices_2', 'html': '<p>Choice 1</p>', }, { 'content_id': 'ca_choices_3', 'html': '<p>Choice 2</p>', }, ] }, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': { 'answer_is_exclusive': True, 'correct_answer': [['<p>Choice 1</p>', '<p>Choice 2</p>']], 'explanation': { 'content_id': 'explanation_1', 'html': 'This is <i>solution</i> for state1', }, }, 'id': 'DragAndDropSortInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=41, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_group_object = ( question.question_state_data.interaction.answer_groups[0] ) solution = question.question_state_data.interaction.solution # Ruling out the possibility of None for mypy type checking. assert solution is not None self.assertEqual( answer_group_object.rule_specs[0].inputs['x'], [['ca_choices_4', 'ca_choices_5', 'invalid_content_id']], ) self.assertEqual( answer_group_object.rule_specs[1].inputs['x'], [['ca_choices_4']] ) self.assertEqual( answer_group_object.rule_specs[2].inputs['x'], 'ca_choices_4' ) self.assertEqual( answer_group_object.rule_specs[3].inputs, {'x': 'ca_choices_4', 'y': 'ca_choices_5'}, ) self.assertEqual( solution.correct_answer, [['ca_choices_4', 'ca_choices_5']] ) def test_migrate_question_state_from_v42_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ {'inputs': {'x': '1/2'}, 'rule_type': 'MatchesExactlyWith'} ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'placeholder': { 'value': { 'content_id': 'ca_placeholder_0', 'unicode_str': ( 'Type an expression here, using only numbers.' ), } } }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [ {'hint_content': {'content_id': 'hint_1', 'html': 'Hint 1'}} ], 'solution': None, 'id': 'NumericExpressionInput', }, 'next_content_id_index': 3, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=42, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args self.assertEqual(cust_args['useFractionForDivision'].value, True) def test_migrate_question_state_from_v43_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['Test']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'ca_placeholder_0': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'placeholder': { 'value': { 'content_id': 'ca_placeholder_0', 'unicode_str': '', } }, 'rows': {'value': 1}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'TextInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=43, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) linked_skill_id = question.question_state_data.linked_skill_id self.assertEqual(linked_skill_id, None) def test_migrate_question_state_from_v44_to_latest(self) -> None: answer_group = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [{'inputs': {'x': ['Test']}, 'rule_type': 'Equals'}], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group], 'confirmed_unclassified_answers': [], 'customization_args': { 'requireNonnegativeInput': {'value': False}, 'rows': {'value': 1}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'NumericInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'card_is_checkpoint': False, 'linked_skill_id': None, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=44, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) cust_args = question.question_state_data.interaction.customization_args self.assertEqual(cust_args['requireNonnegativeInput'].value, False) def test_migrate_question_state_from_v45_to_latest(self) -> None: answer_group1 = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_1', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ {'inputs': {'x': 'a - b'}, 'rule_type': 'ContainsSomeOf'}, {'inputs': {'x': 'a - b'}, 'rule_type': 'MatchesExactlyWith'}, {'inputs': {'x': 'a - b'}, 'rule_type': 'OmitsSomeOf'}, { 'inputs': {'x': 'a - b', 'y': []}, 'rule_type': 'MatchesWithGeneralForm', }, ], 'training_data': [], 'tagged_skill_misconception_id': None, } answer_group2 = { 'outcome': { 'dest': 'abc', 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'feedback_2', 'html': '<p>Feedback</p>', }, 'labelled_as_correct': True, 'param_changes': [], 'refresher_exploration_id': None, 'missing_prerequisite_skill_id': None, }, 'rule_specs': [ {'inputs': {'x': 'a - b'}, 'rule_type': 'ContainsSomeOf'}, { 'inputs': {'x': 'a - b', 'y': []}, 'rule_type': 'MatchesWithGeneralForm', }, ], 'training_data': [], 'tagged_skill_misconception_id': None, } question_state_dict = { 'content': {'content_id': 'content', 'html': 'Question 1'}, 'recorded_voiceovers': { 'voiceovers_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'written_translations': { 'translations_mapping': { 'content': {}, 'explanation_1': {}, 'feedback_1': {}, 'default_outcome_2': {}, 'hint_1': {}, } }, 'interaction': { 'answer_groups': [answer_group1, answer_group2], 'confirmed_unclassified_answers': [], 'customization_args': { 'customOskLetters': {'value': ['a', 'b']}, 'useFractionForDivision': {'value': False}, }, 'default_outcome': { 'dest': None, 'dest_if_really_stuck': None, 'feedback': { 'content_id': 'default_outcome_2', 'html': 'Correct Answer', }, 'param_changes': [], 'refresher_exploration_id': None, 'labelled_as_correct': True, 'missing_prerequisite_skill_id': None, }, 'hints': [], 'solution': None, 'id': 'AlgebraicExpressionInput', }, 'next_content_id_index': 4, 'param_changes': [], 'solicit_answer_details': False, 'card_is_checkpoint': False, 'linked_skill_id': None, 'classifier_model_id': None, } question_model = question_models.QuestionModel( id='question_id', question_state_data=question_state_dict, language_code='en', version=0, linked_skill_ids=['skill_id'], question_state_data_schema_version=45, ) commit_cmd = question_domain.QuestionChange( {'cmd': question_domain.CMD_CREATE_NEW} ) commit_cmd_dicts = [commit_cmd.to_dict()] question_model.commit( 'user_id_admin', 'question model created', commit_cmd_dicts ) question = question_fetchers.get_question_from_model(question_model) self.assertEqual( question.question_state_data_schema_version, feconf.CURRENT_STATE_SCHEMA_VERSION, ) answer_groups = question.question_state_data.interaction.answer_groups self.assertEqual(len(answer_groups), 1) rule_specs = answer_groups[0].rule_specs self.assertEqual(len(rule_specs), 1) self.assertEqual(rule_specs[0].rule_type, 'MatchesExactlyWith') cust_args = question.question_state_data.interaction.customization_args self.assertNotIn('customOskLetters', cust_args) self.assertIn('allowedVariables', cust_args)