"""Unit tests for XBlock field serialization, deserialization, and XML attributes.""" import unittest from unittest.mock import Mock import dateutil.parser from lxml import etree from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from xblock.field_data import DictFieldData from xblock.fields import Any, Boolean, Date, Dict, Float, Integer, List, RelativeTime, Scope, String, Timedelta from xblock.runtime import DictKeyValueStore, KvsFieldData from xmodule.course_block import CourseBlock 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): # pylint: disable=too-few-public-methods """String field that appends ' JSON' when serialized.""" def to_json(self, value): """Return the string value appended with ' JSON'.""" return value + " JSON" class TestFields: # pylint: disable=too-few-public-methods """XBlock fields for testing editable and inherited behavior.""" # 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=0.999, values=[1.23, 0.98]) # Used for testing float type float_non_select = Float(scope=Scope.settings, default=0.999, values={"min": 0, "step": 0.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): """ An XBlock we can use in these tests. """ inherited = String(scope=Scope.settings, default="the default") not_inherited = String(scope=Scope.settings, default="nothing") @classmethod def definition_from_xml(cls, xml_object, system): return {}, [] def definition_to_xml(self, resource_fs): return etree.Element("test_block") 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={"inherited": "changed!"}, defaults={"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={"inherited": "changed!"}, defaults={"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={"inherited": "child's default"}, ) child.parent = parent_block.location assert child.inherited == "child's default" class EditableMetadataFieldsTest(unittest.TestCase): """Tests editable metadata fields and their serialization.""" class TestableXmlXBlock(XmlMixin, XModuleMixin): """ This is subclassing `XModuleMixin` to use metadata fields in the unmixed class. """ @classmethod def definition_from_xml(cls, xml_object, system): return {}, [] def definition_to_xml(self, resource_fs): return etree.Element("test_block") def test_display_name_field(self): """Test filtering and values of display_name editable metadata field.""" 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): """Test explicitly set values override defaults for editable fields.""" # 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): """Test serialization and options of Integer metadata fields.""" 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): """Test inheritance behavior of editable metadata fields.""" 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 type and options representation of various editable metadata fields.""" # 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=0.999, default_value=0.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=0.999, default_value=0.999, type="Float", options={"min": 0, "step": 0.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): """Return editable fields from a test XML XBlock with given 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): """Construct a test XBlock combining test fields and XML behavior.""" class TestModuleBlock(TestFields, self.TestableXmlXBlock): # pylint: disable=too-many-ancestors """Test XBlock class combining test fields and XML behavior, overriding non-editable fields.""" @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( # pylint: disable=dangerous-default-value,too-many-arguments,too-many-positional-arguments self, editable_fields, name, field, explicitly_set, value, default_value, type="Generic", # pylint: disable=redefined-builtin options=[], ): """Assert correctness of field values, type, options, and explicitness.""" 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): """Test serialization of various field types to JSON-compatible strings.""" 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 in ('{"bar": "hat", "frog": "green"}', '{"frog": "green", "bar": "hat"}') 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): """Base class for testing deserialization of field values.""" def assert_deserialize_equal(self, expected, arg): """ Asserts the result of deserialize_field. """ assert deserialize_field(self.field_type(), arg) == expected # pylint: disable=no-member def assert_deserialize_non_string(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.assert_deserialize_equal(None, None) self.assert_deserialize_equal(3.14, 3.14) self.assert_deserialize_equal(True, True) self.assert_deserialize_equal([10], [10]) self.assert_deserialize_equal({}, {}) self.assert_deserialize_equal([], []) self.assert_deserialize_equal(None, "null") class TestDeserializeInteger(TestDeserialize): """Tests deserialize as related to Integer type.""" field_type = Integer def test_deserialize(self): """Test deserialization of Integer field values.""" self.assert_deserialize_equal(-2, "-2") self.assert_deserialize_equal("450", '"450"') # False can be parsed as a int (converts to 0) self.assert_deserialize_equal(False, "false") # True can be parsed as a int (converts to 1) self.assert_deserialize_equal(True, "true") # 2.78 can be converted to int, so the string will be deserialized self.assert_deserialize_equal(-2.78, "-2.78") def test_deserialize_unsupported_types(self): """Test deserialization handles unsupported Integer inputs gracefully.""" self.assert_deserialize_equal("[3]", "[3]") # '2.78' cannot be converted to int, so input value is returned self.assert_deserialize_equal('"-2.78"', '"-2.78"') # 'false' cannot be converted to int, so input value is returned self.assert_deserialize_equal('"false"', '"false"') self.assert_deserialize_non_string() class TestDeserializeFloat(TestDeserialize): """Tests deserialize as related to Float type.""" field_type = Float def test_deserialize(self): """Test deserialization of Float field values.""" self.assert_deserialize_equal(-2, "-2") self.assert_deserialize_equal("450", '"450"') self.assert_deserialize_equal(-2.78, "-2.78") self.assert_deserialize_equal("0.45", '"0.45"') # False can be parsed as a float (converts to 0) self.assert_deserialize_equal(False, "false") # True can be parsed as a float (converts to 1) self.assert_deserialize_equal(True, "true") def test_deserialize_unsupported_types(self): """Test deserialization handles unsupported Float inputs gracefully.""" self.assert_deserialize_equal("[3]", "[3]") # 'false' cannot be converted to float, so input value is returned self.assert_deserialize_equal('"false"', '"false"') self.assert_deserialize_non_string() class TestDeserializeBoolean(TestDeserialize): """Tests deserialize as related to Boolean type.""" field_type = Boolean def test_deserialize(self): """Test deserialization of Boolean field values.""" # json.loads converts the value to Python bool self.assert_deserialize_equal(False, "false") self.assert_deserialize_equal(True, "true") # json.loads fails, string value is returned. self.assert_deserialize_equal("False", "False") self.assert_deserialize_equal("True", "True") # json.loads deserializes as a string self.assert_deserialize_equal("false", '"false"') self.assert_deserialize_equal("fAlse", '"fAlse"') self.assert_deserialize_equal("TruE", '"TruE"') # 2.78 can be converted to a bool, so the string will be deserialized self.assert_deserialize_equal(-2.78, "-2.78") self.assert_deserialize_non_string() class TestDeserializeString(TestDeserialize): """Tests deserialize as related to String type.""" field_type = String def test_deserialize(self): """Test deserialization of String field values.""" self.assert_deserialize_equal("hAlf", '"hAlf"') self.assert_deserialize_equal("false", '"false"') self.assert_deserialize_equal("single quote", "single quote") def test_deserialize_unsupported_types(self): """Test deserialization handles unsupported String inputs gracefully.""" self.assert_deserialize_equal("3.4", "3.4") self.assert_deserialize_equal("false", "false") self.assert_deserialize_equal("2", "2") self.assert_deserialize_equal("[3]", "[3]") self.assert_deserialize_non_string() class TestDeserializeAny(TestDeserialize): """Tests deserialize as related to Any type.""" field_type = Any def test_deserialize(self): """Test deserialization of Any-type field values.""" self.assert_deserialize_equal("hAlf", '"hAlf"') self.assert_deserialize_equal("false", '"false"') self.assert_deserialize_equal({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}') self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]") self.assert_deserialize_equal("[", "[") self.assert_deserialize_equal(False, "false") self.assert_deserialize_equal(3.4, "3.4") self.assert_deserialize_non_string() class TestDeserializeList(TestDeserialize): """Tests deserialize as related to List type.""" field_type = List def test_deserialize(self): """Test deserialization of List field values.""" self.assert_deserialize_equal(["foo", "bar"], '["foo", "bar"]') self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]") self.assert_deserialize_equal([], "[]") def test_deserialize_unsupported_types(self): """Test deserialization handles unsupported List inputs gracefully.""" self.assert_deserialize_equal("3.4", "3.4") self.assert_deserialize_equal("false", "false") self.assert_deserialize_equal("2", "2") self.assert_deserialize_non_string() class TestDeserializeDate(TestDeserialize): """Test deserialization of Date field values.""" field_type = Date def test_deserialize(self): """Test deserialization of Timedelta field values.""" self.assert_deserialize_equal("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z") self.assert_deserialize_equal("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"') self.assert_deserialize_non_string() class TestDeserializeTimedelta(TestDeserialize): """Tests deserialize as related to Timedelta type.""" field_type = Timedelta def test_deserialize(self): """Test deserialization of RelativeTime field values.""" self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds") self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"') self.assert_deserialize_non_string() class TestDeserializeRelativeTime(TestDeserialize): """Tests deserialize as related to Timedelta type.""" field_type = RelativeTime def test_deserialize(self): """ There is no check for self.assert_deserialize_equal('10:20:30', '10:20:30') self.assert_deserialize_non_string() 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.assert_deserialize_equal("10:20:30", '"10:20:30"') class TestXmlAttributes(XModuleXmlImportTest): """Tests XML import/export of XBlock attributes, including known, unknown, and inheritable attributes.""" def test_unknown_attribute(self): """Test processing and retention of unknown XML attributes.""" 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): """Test that known XML attributes are correctly assigned to XBlock fields.""" 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): """Test that rerandomize attribute from policy is correctly processed.""" # 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): """Test that attempts attribute from policy is correctly handled in XML import.""" # 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): """Check that an inheritable attribute is correctly processed and excluded from XML attributes.""" # `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): """Check multiple inheritable attributes are processed correctly from XML.""" 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)