# disable missing docstring # pylint: disable=missing-docstring import unittest from unittest.mock import Mock import dateutil.parser from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from xblock.field_data import DictFieldData from xblock.fields import Any, Boolean, Dict, Float, Integer, List, Scope, String from xblock.runtime import DictKeyValueStore, KvsFieldData from xmodule.course_block import CourseBlock from xmodule.fields import Date, RelativeTime, Timedelta from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin, InheritingFieldData from xmodule.modulestore.split_mongo.split_mongo_kvs import SplitMongoKVS from xmodule.seq_block import SequenceBlock from xmodule.tests import get_test_descriptor_system from xmodule.tests.xml import XModuleXmlImportTest from xmodule.tests.xml.factories import CourseFactory, ProblemFactory, SequenceFactory from xmodule.x_module import XModuleMixin from xmodule.xml_block import XmlMixin, deserialize_field, serialize_field class CrazyJsonString(String): def to_json(self, value): return value + " JSON" class TestFields: # Will be returned by editable_metadata_fields. max_attempts = Integer(scope=Scope.settings, default=1000, values={'min': 1, 'max': 10}) # Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields. due = Date(scope=Scope.settings) # Will not be returned by editable_metadata_fields because is not Scope.settings. student_answers = Dict(scope=Scope.user_state) # Will be returned, and can override the inherited value from XModule. display_name = String( scope=Scope.settings, default='local default', display_name='Local Display Name', help='local help' ) # Used for testing select type, effect of to_json method string_select = CrazyJsonString( scope=Scope.settings, default='default value', values=[{'display_name': 'first', 'value': 'value a'}, {'display_name': 'second', 'value': 'value b'}] ) showanswer = InheritanceMixin.showanswer # Used for testing select type float_select = Float(scope=Scope.settings, default=.999, values=[1.23, 0.98]) # Used for testing float type float_non_select = Float(scope=Scope.settings, default=.999, values={'min': 0, 'step': .3}) # Used for testing that Booleans get mapped to select type boolean_select = Boolean(scope=Scope.settings) # Used for testing Lists list_field = List(scope=Scope.settings, default=[]) class InheritingFieldDataTest(unittest.TestCase): """ Tests of InheritingFieldData. """ class TestableInheritingXBlock(XmlMixin): # lint-amnesty, pylint: disable=abstract-method """ An XBlock we can use in these tests. """ inherited = String(scope=Scope.settings, default="the default") not_inherited = String(scope=Scope.settings, default="nothing") def setUp(self): super().setUp() self.dummy_course_key = CourseLocator('test_org', 'test_123', 'test_run') self.system = get_test_descriptor_system() self.all_blocks = {} self.system.get_block = self.all_blocks.get self.field_data = InheritingFieldData( inheritable_names=['inherited'], kvs=DictKeyValueStore({}), ) def get_block_using_split_kvs(self, block_type, block_id, fields, defaults): """ Construct an Xblock with split mongo kvs. """ kvs = SplitMongoKVS( definition=Mock(), initial_values=fields, default_values=defaults, parent=None ) self.field_data = InheritingFieldData( inheritable_names=['inherited'], kvs=kvs, ) block = self.get_a_block( usage_id=self.get_usage_id(block_type, block_id) ) return block def get_a_block(self, usage_id=None): """ Construct an XBlock for testing with. """ scope_ids = Mock() if usage_id is None: block_id = f"_auto{len(self.all_blocks)}" usage_id = self.get_usage_id("course", block_id) scope_ids.usage_id = usage_id block = self.system.construct_xblock_from_class( self.TestableInheritingXBlock, field_data=self.field_data, scope_ids=scope_ids, ) self.all_blocks[usage_id] = block return block def get_usage_id(self, block_type, block_id): """ Constructs usage id using 'block_type' and 'block_id' """ return BlockUsageLocator(self.dummy_course_key, block_type=block_type, block_id=block_id) def test_default_value(self): """ Test that the Blocks with nothing set with return the fields' defaults. """ block = self.get_a_block() assert block.inherited == 'the default' assert block.not_inherited == 'nothing' def test_set_value(self): """ Test that If you set a value, that's what you get back. """ block = self.get_a_block() block.inherited = "Changed!" block.not_inherited = "New Value!" assert block.inherited == 'Changed!' assert block.not_inherited == 'New Value!' def test_inherited(self): """ Test that a child with get a value inherited from the parent. """ parent_block = self.get_a_block(usage_id=self.get_usage_id("course", "parent")) parent_block.inherited = "Changed!" assert parent_block.inherited == 'Changed!' child = self.get_a_block(usage_id=self.get_usage_id("vertical", "child")) child.parent = parent_block.location assert child.inherited == 'Changed!' def test_inherited_across_generations(self): """ Test that a child with get a value inherited from a great-grandparent. """ parent = self.get_a_block(usage_id=self.get_usage_id("course", "parent")) parent.inherited = "Changed!" assert parent.inherited == 'Changed!' for child_num in range(10): usage_id = self.get_usage_id("vertical", f"child_{child_num}") child = self.get_a_block(usage_id=usage_id) child.parent = parent.location assert child.inherited == 'Changed!' def test_not_inherited(self): """ Test that the fields not in the inherited_names list won't be inherited. """ parent = self.get_a_block(usage_id=self.get_usage_id("course", "parent")) parent.not_inherited = "Changed!" assert parent.not_inherited == 'Changed!' child = self.get_a_block(usage_id=self.get_usage_id("vertical", "child")) child.parent = parent.location assert child.not_inherited == 'nothing' def test_non_defaults_inherited_across_lib(self): """ Test that a child inheriting from library_content block, inherits fields from parent if these fields are not in its defaults. """ parent_block = self.get_block_using_split_kvs( block_type="library_content", block_id="parent", fields=dict(inherited="changed!"), defaults=dict(inherited="parent's default"), ) assert parent_block.inherited == 'changed!' child = self.get_block_using_split_kvs( block_type="problem", block_id="child", fields={}, defaults={}, ) child.parent = parent_block.location assert child.inherited == 'changed!' def test_defaults_not_inherited_across_lib(self): """ Test that a child inheriting from library_content block, does not inherit fields from parent if these fields are in its defaults already. """ parent_block = self.get_block_using_split_kvs( block_type="library_content", block_id="parent", fields=dict(inherited="changed!"), defaults=dict(inherited="parent's default"), ) assert parent_block.inherited == 'changed!' child = self.get_block_using_split_kvs( block_type="library_content", block_id="parent", fields={}, defaults=dict(inherited="child's default"), ) child.parent = parent_block.location assert child.inherited == "child's default" class EditableMetadataFieldsTest(unittest.TestCase): class TestableXmlXBlock(XmlMixin, XModuleMixin): # lint-amnesty, pylint: disable=abstract-method """ This is subclassing `XModuleMixin` to use metadata fields in the unmixed class. """ def test_display_name_field(self): editable_fields = self.get_xml_editable_fields(DictFieldData({})) # Tests that the xblock fields (currently tags and name) get filtered out. # Also tests that xml_attributes is filtered out of XmlMixin. assert 1 == len(editable_fields), editable_fields self.assert_field_values( editable_fields, 'display_name', XModuleMixin.display_name, explicitly_set=False, value=None, default_value=None ) def test_override_default(self): # Tests that explicitly_set is correct when a value overrides the default (not inheritable). editable_fields = self.get_xml_editable_fields(DictFieldData({'display_name': 'foo'})) self.assert_field_values( editable_fields, 'display_name', XModuleMixin.display_name, explicitly_set=True, value='foo', default_value=None ) def test_integer_field(self): block = self.get_block(DictFieldData({'max_attempts': '7'})) editable_fields = block.editable_metadata_fields assert 8 == len(editable_fields) self.assert_field_values( editable_fields, 'max_attempts', TestFields.max_attempts, explicitly_set=True, value=7, default_value=1000, type='Integer', options=TestFields.max_attempts.values ) self.assert_field_values( editable_fields, 'display_name', TestFields.display_name, explicitly_set=False, value='local default', default_value='local default' ) editable_fields = self.get_block(DictFieldData({})).editable_metadata_fields self.assert_field_values( editable_fields, 'max_attempts', TestFields.max_attempts, explicitly_set=False, value=1000, default_value=1000, type='Integer', options=TestFields.max_attempts.values ) def test_inherited_field(self): kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={'showanswer': 'inherited'}) model_data = KvsFieldData(kvs) block = self.get_block(model_data) editable_fields = block.editable_metadata_fields self.assert_field_values( editable_fields, 'showanswer', InheritanceMixin.showanswer, explicitly_set=False, value='inherited', default_value='inherited' ) # Mimic the case where display_name WOULD have been inherited, except we explicitly set it. kvs = InheritanceKeyValueStore( initial_values={'showanswer': 'explicit'}, inherited_settings={'showanswer': 'inheritable value'} ) model_data = KvsFieldData(kvs) block = self.get_block(model_data) editable_fields = block.editable_metadata_fields self.assert_field_values( editable_fields, 'showanswer', InheritanceMixin.showanswer, explicitly_set=True, value='explicit', default_value='inheritable value' ) def test_type_and_options(self): # test_display_name_field verifies that a String field is of type "Generic". # test_integer_field verifies that a Integer field is of type "Integer". block = self.get_block(DictFieldData({})) editable_fields = block.editable_metadata_fields # Tests for select self.assert_field_values( editable_fields, 'string_select', TestFields.string_select, explicitly_set=False, value='default value', default_value='default value', type='Select', options=[{'display_name': 'first', 'value': 'value a JSON'}, {'display_name': 'second', 'value': 'value b JSON'}] ) self.assert_field_values( editable_fields, 'float_select', TestFields.float_select, explicitly_set=False, value=.999, default_value=.999, type='Select', options=[1.23, 0.98] ) self.assert_field_values( editable_fields, 'boolean_select', TestFields.boolean_select, explicitly_set=False, value=None, default_value=None, type='Select', options=[{'display_name': "True", "value": True}, {'display_name': "False", "value": False}] ) # Test for float self.assert_field_values( editable_fields, 'float_non_select', TestFields.float_non_select, explicitly_set=False, value=.999, default_value=.999, type='Float', options={'min': 0, 'step': .3} ) self.assert_field_values( editable_fields, 'list_field', TestFields.list_field, explicitly_set=False, value=[], default_value=[], type='List' ) # Start of helper methods def get_xml_editable_fields(self, field_data): runtime = get_test_descriptor_system() return runtime.construct_xblock_from_class( self.TestableXmlXBlock, scope_ids=Mock(), field_data=field_data, ).editable_metadata_fields def get_block(self, field_data): class TestModuleBlock(TestFields, self.TestableXmlXBlock): # lint-amnesty, pylint: disable=abstract-method @property def non_editable_metadata_fields(self): non_editable_fields = super().non_editable_metadata_fields non_editable_fields.append(TestModuleBlock.due) return non_editable_fields system = get_test_descriptor_system(render_template=Mock()) return system.construct_xblock_from_class(TestModuleBlock, field_data=field_data, scope_ids=Mock()) def assert_field_values(self, editable_fields, name, field, explicitly_set, value, default_value, # lint-amnesty, pylint: disable=dangerous-default-value type='Generic', options=[]): # lint-amnesty, pylint: disable=redefined-builtin test_field = editable_fields[name] assert field.name == test_field['field_name'] assert field.display_name == test_field['display_name'] assert field.help == test_field['help'] assert field.to_json(value) == test_field['value'] assert field.to_json(default_value) == test_field['default_value'] assert options == test_field['options'] assert type == test_field['type'] assert explicitly_set == test_field['explicitly_set'] class TestSerialize(unittest.TestCase): """ Tests the serialize, method, which is not dependent on type. """ def test_serialize(self): assert serialize_field(None) == 'null' assert serialize_field(-2) == '-2' assert serialize_field('2') == '2' assert serialize_field(-3.41) == '-3.41' assert serialize_field('2.589') == '2.589' assert serialize_field(False) == 'false' assert serialize_field('false') == 'false' assert serialize_field('fAlse') == 'fAlse' assert serialize_field('hat box') == 'hat box' serialized_dict = serialize_field({'bar': 'hat', 'frog': 'green'}) assert serialized_dict == '{"bar": "hat", "frog": "green"}' or serialized_dict == '{"frog": "green", "bar": "hat"}' # lint-amnesty, pylint: disable=consider-using-in, line-too-long assert serialize_field([3.5, 5.6]) == '[3.5, 5.6]' assert serialize_field(['foo', 'bar']) == '["foo", "bar"]' assert serialize_field("2012-12-31T23:59:59Z") == '2012-12-31T23:59:59Z' assert serialize_field("1 day 12 hours 59 minutes 59 seconds") == '1 day 12 hours 59 minutes 59 seconds' assert serialize_field(dateutil.parser.parse('2012-12-31T23:59:59Z')) == '2012-12-31T23:59:59+00:00' class TestDeserialize(unittest.TestCase): def assertDeserializeEqual(self, expected, arg): """ Asserts the result of deserialize_field. """ assert deserialize_field(self.field_type(), arg) == expected # lint-amnesty, pylint: disable=no-member def assertDeserializeNonString(self): """ Asserts input value is returned for None or something that is not a string. For all types, 'null' is also always returned as None. """ self.assertDeserializeEqual(None, None) self.assertDeserializeEqual(3.14, 3.14) self.assertDeserializeEqual(True, True) self.assertDeserializeEqual([10], [10]) self.assertDeserializeEqual({}, {}) self.assertDeserializeEqual([], []) self.assertDeserializeEqual(None, 'null') class TestDeserializeInteger(TestDeserialize): """ Tests deserialize as related to Integer type. """ field_type = Integer def test_deserialize(self): self.assertDeserializeEqual(-2, '-2') self.assertDeserializeEqual("450", '"450"') # False can be parsed as a int (converts to 0) self.assertDeserializeEqual(False, 'false') # True can be parsed as a int (converts to 1) self.assertDeserializeEqual(True, 'true') # 2.78 can be converted to int, so the string will be deserialized self.assertDeserializeEqual(-2.78, '-2.78') def test_deserialize_unsupported_types(self): self.assertDeserializeEqual('[3]', '[3]') # '2.78' cannot be converted to int, so input value is returned self.assertDeserializeEqual('"-2.78"', '"-2.78"') # 'false' cannot be converted to int, so input value is returned self.assertDeserializeEqual('"false"', '"false"') self.assertDeserializeNonString() class TestDeserializeFloat(TestDeserialize): """ Tests deserialize as related to Float type. """ field_type = Float def test_deserialize(self): self.assertDeserializeEqual(-2, '-2') self.assertDeserializeEqual("450", '"450"') self.assertDeserializeEqual(-2.78, '-2.78') self.assertDeserializeEqual("0.45", '"0.45"') # False can be parsed as a float (converts to 0) self.assertDeserializeEqual(False, 'false') # True can be parsed as a float (converts to 1) self.assertDeserializeEqual(True, 'true') def test_deserialize_unsupported_types(self): self.assertDeserializeEqual('[3]', '[3]') # 'false' cannot be converted to float, so input value is returned self.assertDeserializeEqual('"false"', '"false"') self.assertDeserializeNonString() class TestDeserializeBoolean(TestDeserialize): """ Tests deserialize as related to Boolean type. """ field_type = Boolean def test_deserialize(self): # json.loads converts the value to Python bool self.assertDeserializeEqual(False, 'false') self.assertDeserializeEqual(True, 'true') # json.loads fails, string value is returned. self.assertDeserializeEqual('False', 'False') self.assertDeserializeEqual('True', 'True') # json.loads deserializes as a string self.assertDeserializeEqual('false', '"false"') self.assertDeserializeEqual('fAlse', '"fAlse"') self.assertDeserializeEqual("TruE", '"TruE"') # 2.78 can be converted to a bool, so the string will be deserialized self.assertDeserializeEqual(-2.78, '-2.78') self.assertDeserializeNonString() class TestDeserializeString(TestDeserialize): """ Tests deserialize as related to String type. """ field_type = String def test_deserialize(self): self.assertDeserializeEqual('hAlf', '"hAlf"') self.assertDeserializeEqual('false', '"false"') self.assertDeserializeEqual('single quote', 'single quote') def test_deserialize_unsupported_types(self): self.assertDeserializeEqual('3.4', '3.4') self.assertDeserializeEqual('false', 'false') self.assertDeserializeEqual('2', '2') self.assertDeserializeEqual('[3]', '[3]') self.assertDeserializeNonString() class TestDeserializeAny(TestDeserialize): """ Tests deserialize as related to Any type. """ field_type = Any def test_deserialize(self): self.assertDeserializeEqual('hAlf', '"hAlf"') self.assertDeserializeEqual('false', '"false"') self.assertDeserializeEqual({'bar': 'hat', 'frog': 'green'}, '{"bar": "hat", "frog": "green"}') self.assertDeserializeEqual([3.5, 5.6], '[3.5, 5.6]') self.assertDeserializeEqual('[', '[') self.assertDeserializeEqual(False, 'false') self.assertDeserializeEqual(3.4, '3.4') self.assertDeserializeNonString() class TestDeserializeList(TestDeserialize): """ Tests deserialize as related to List type. """ field_type = List def test_deserialize(self): self.assertDeserializeEqual(['foo', 'bar'], '["foo", "bar"]') self.assertDeserializeEqual([3.5, 5.6], '[3.5, 5.6]') self.assertDeserializeEqual([], '[]') def test_deserialize_unsupported_types(self): self.assertDeserializeEqual('3.4', '3.4') self.assertDeserializeEqual('false', 'false') self.assertDeserializeEqual('2', '2') self.assertDeserializeNonString() class TestDeserializeDate(TestDeserialize): """ Tests deserialize as related to Date type. """ field_type = Date def test_deserialize(self): self.assertDeserializeEqual('2012-12-31T23:59:59Z', "2012-12-31T23:59:59Z") self.assertDeserializeEqual('2012-12-31T23:59:59Z', '"2012-12-31T23:59:59Z"') self.assertDeserializeNonString() class TestDeserializeTimedelta(TestDeserialize): """ Tests deserialize as related to Timedelta type. """ field_type = Timedelta def test_deserialize(self): self.assertDeserializeEqual( '1 day 12 hours 59 minutes 59 seconds', '1 day 12 hours 59 minutes 59 seconds' ) self.assertDeserializeEqual( '1 day 12 hours 59 minutes 59 seconds', '"1 day 12 hours 59 minutes 59 seconds"' ) self.assertDeserializeNonString() class TestDeserializeRelativeTime(TestDeserialize): """ Tests deserialize as related to Timedelta type. """ field_type = RelativeTime def test_deserialize(self): """ There is no check for self.assertDeserializeEqual('10:20:30', '10:20:30') self.assertDeserializeNonString() because these two tests work only because json.loads fires exception, and xml_module.deserialized_field catches it and returns same value, so there is nothing field-specific here. But other modules do it, so I'm leaving this comment for PR reviewers. """ # test that from_json produces no exceptions self.assertDeserializeEqual('10:20:30', '"10:20:30"') class TestXmlAttributes(XModuleXmlImportTest): def test_unknown_attribute(self): assert not hasattr(CourseBlock, 'unknown_attr') course = self.process_xml(CourseFactory.build(unknown_attr='value')) assert not hasattr(course, 'unknown_attr') assert course.xml_attributes['unknown_attr'] == 'value' def test_known_attribute(self): assert hasattr(CourseBlock, 'show_calculator') course = self.process_xml(CourseFactory.build(show_calculator='true')) assert course.show_calculator assert 'show_calculator' not in course.xml_attributes def test_rerandomize_in_policy(self): # Rerandomize isn't a basic attribute of Sequence assert not hasattr(SequenceBlock, 'rerandomize') root = SequenceFactory.build(policy={'rerandomize': 'never'}) ProblemFactory.build(parent=root) seq = self.process_xml(root) # Rerandomize is added to the constructed sequence via the InheritanceMixin assert seq.rerandomize == 'never' # Rerandomize is a known value coming from policy, and shouldn't appear # in xml_attributes assert 'rerandomize' not in seq.xml_attributes def test_attempts_in_policy(self): # attempts isn't a basic attribute of Sequence assert not hasattr(SequenceBlock, 'attempts') root = SequenceFactory.build(policy={'attempts': '1'}) ProblemFactory.build(parent=root) seq = self.process_xml(root) # attempts isn't added to the constructed sequence, because # it's not in the InheritanceMixin assert not hasattr(seq, 'attempts') # attempts is an unknown attribute, so we should include it # in xml_attributes so that it gets written out (despite the misleading # name) assert 'attempts' in seq.xml_attributes def check_inheritable_attribute(self, attribute, value): # `attribute` isn't a basic attribute of Sequence assert not hasattr(SequenceBlock, attribute) # `attribute` is added by InheritanceMixin assert hasattr(InheritanceMixin, attribute) root = SequenceFactory.build(policy={attribute: str(value)}) ProblemFactory.build(parent=root) # InheritanceMixin will be used when processing the XML assert InheritanceMixin in root.xblock_mixins seq = self.process_xml(root) assert seq.unmixed_class == SequenceBlock assert not seq.__class__ == SequenceBlock # `attribute` is added to the constructed sequence, because # it's in the InheritanceMixin assert getattr(seq, attribute) == value # `attribute` is a known attribute, so we shouldn't include it # in xml_attributes assert attribute not in seq.xml_attributes def test_inheritable_attributes(self): self.check_inheritable_attribute('days_early_for_beta', 2) self.check_inheritable_attribute('max_attempts', 5) self.check_inheritable_attribute('visible_to_staff_only', True)