test: Allow for Extracted Word Cloud Block | Fix Test Cases (#35983)

Run tests for both the built-in and extracted WordCloud block.
The tests are mostly compatible with both versions of the block,
except for a few places where the XBlock framework and the
built-in XModule system differ which we've had to handle using
conditionals.

This moves us closer to enabling the extracted WordCloud block
by default and eventually removing the built-in block.

Part of: https://github.com/openedx/edx-platform/issues/34840
This commit is contained in:
Muhammad Farhan Khan
2025-08-08 20:22:26 +05:00
committed by GitHub
parent 17f0e256bd
commit 4a9fc77ecb
4 changed files with 202 additions and 58 deletions

View File

@@ -138,11 +138,11 @@ class BaseTestXmodule(ModuleStoreTestCase):
self.setup_course()
self.initialize_module(metadata=self.METADATA, data=self.DATA)
def get_url(self, dispatch):
def get_url(self, dispatch, handler_name='xmodule_handler'):
"""Return item url with dispatch."""
return reverse(
'xblock_handler',
args=(str(self.course.id), quote_slashes(self.item_url), 'xmodule_handler', dispatch)
args=(str(self.course.id), quote_slashes(self.item_url), handler_name, dispatch)
)

View File

@@ -1,23 +1,40 @@
"""Word cloud integration tests using mongo modulestore."""
import importlib
import json
import re
from operator import itemgetter
from unittest.mock import patch
from uuid import UUID
import pytest
from django.conf import settings
from django.test import override_settings
from xblock import plugin
import json
from operator import itemgetter
from common.djangoapps.student.tests.factories import RequestFactoryNoCsrf
from xmodule import word_cloud_block
# noinspection PyUnresolvedReferences
from xmodule.tests.helpers import override_descriptor_system # pylint: disable=unused-import
from xmodule.tests.helpers import override_descriptor_system, mock_render_template # pylint: disable=unused-import
from xmodule.x_module import STUDENT_VIEW
from .helpers import BaseTestXmodule
@pytest.mark.usefixtures("override_descriptor_system")
class TestWordCloud(BaseTestXmodule):
class _TestWordCloudBase(BaseTestXmodule):
"""Integration test for Word Cloud Block."""
__test__ = False
CATEGORY = "word_cloud"
@classmethod
def setUpClass(cls):
super().setUpClass()
plugin.PLUGIN_CACHE = {}
importlib.reload(word_cloud_block)
def setUp(self):
super().setUp()
self.request_factory = RequestFactoryNoCsrf()
def _get_users_state(self):
"""Return current state for each user:
@@ -27,7 +44,18 @@ class TestWordCloud(BaseTestXmodule):
users_state = {}
for user in self.users:
response = self.clients[user.username].post(self.get_url('get_state'))
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# The extracted Word Cloud XBlock uses @XBlock.json_handler, which expects a different
# request format and url pattern
handler_url = self.get_url('', handler_name='handle_get_state')
response = self.clients[user.username].post(
handler_url,
data=json.dumps({}),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
else:
response = self.clients[user.username].post(self.get_url('get_state'))
users_state[user.username] = json.loads(response.content.decode('utf-8'))
return users_state
@@ -40,11 +68,22 @@ class TestWordCloud(BaseTestXmodule):
users_state = {}
for user in self.users:
response = self.clients[user.username].post(
self.get_url('submit'),
{'student_words[]': words},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# The extracted Word Cloud XBlock uses @XBlock.json_handler, which expects a different
# request format and url pattern
handler_url = self.get_url('', handler_name='handle_submit_state')
response = self.clients[user.username].post(
handler_url,
data=json.dumps({'student_words': words}),
content_type='application/json',
HTTP_X_REQUESTED_WITH='XMLHttpRequest',
)
else:
response = self.clients[user.username].post(
self.get_url('submit'),
{'student_words[]': words},
HTTP_X_REQUESTED_WITH='XMLHttpRequest'
)
users_state[user.username] = json.loads(response.content.decode('utf-8'))
return users_state
@@ -52,7 +91,6 @@ class TestWordCloud(BaseTestXmodule):
def _check_response(self, response_contents, correct_jsons):
"""Utility function that compares correct and real responses."""
for username, content in response_contents.items():
# Used in debugger for comparing objects.
# self.maxDiff = None
@@ -120,7 +158,6 @@ class TestWordCloud(BaseTestXmodule):
correct_state = {}
for index, user in enumerate(self.users):
correct_state[user.username] = {
'status': 'success',
'submitted': True,
@@ -202,6 +239,14 @@ class TestWordCloud(BaseTestXmodule):
for user in self.users
}
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# The extracted Word Cloud XBlock uses @XBlock.json_handler to handle AJAX requests,
# which automatically returns a 404 for unknown requests, so there's no need to test
# the incorrect dispatch case in this scenario.
for username, response in responses.items():
self.assertEqual(response.status_code, 404)
return
status_codes = {response.status_code for response in responses.values()}
assert status_codes.pop() == 200
@@ -214,19 +259,44 @@ class TestWordCloud(BaseTestXmodule):
}
)
def test_word_cloud_constructor(self):
@patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template)
def test_word_cloud_constructor(self, mock_render_django_template):
"""
Make sure that all parameters extracted correctly from xml.
"""
fragment = self.runtime.render(self.block, STUDENT_VIEW)
expected_context = {
'ajax_url': self.block.ajax_url,
'display_name': self.block.display_name,
'instructions': self.block.instructions,
'element_class': self.block.location.block_type,
'element_id': self.block.location.html_id(),
'element_class': self.block.scope_ids.block_type,
'num_inputs': 5, # default value
'submitted': False, # default value,
}
assert fragment.content == self.runtime.render_template('word_cloud.html', expected_context)
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# If `USE_EXTRACTED_WORD_CLOUD_BLOCK` is enabled, the `expected_context` will be different
# because in the extracted Word Cloud XBlock, the expected context:
# - contains `range_num_inputs`
# - uses `UUID` for `element_id` instead of `html_id()`
# - does not include `ajax_url` since it uses the `@XBlock.json_handler` decorator for AJAX requests
expected_context['range_num_inputs'] = range(5)
uuid_str = re.search(r"UUID\('([a-f0-9\-]+)'\)", fragment.content).group(1)
expected_context['element_id'] = UUID(uuid_str)
mock_render_django_template.assert_called_once()
# Remove i18n service
fragment_content_clean = re.sub(r"\{.*?}", "{}", fragment.content)
assert fragment_content_clean == self.runtime.render_template('templates/word_cloud.html', expected_context)
else:
expected_context['ajax_url'] = self.block.ajax_url
expected_context['element_id'] = self.block.location.html_id()
assert fragment.content == self.runtime.render_template('word_cloud.html', expected_context)
@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=True)
class TestWordCloudExtracted(_TestWordCloudBase):
__test__ = True
@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=False)
class TestWordCloudBuiltIn(_TestWordCloudBase):
__test__ = True

View File

@@ -1,38 +1,48 @@
"""Test for Word Cloud Block functional logic."""
import json
import os
from unittest.mock import Mock
from django.conf import settings
from django.test import TestCase
from django.test import override_settings
from fs.memoryfs import MemoryFS
from lxml import etree
from webob import Request
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from webob import Request
from webob.multidict import MultiDict
from xblock.field_data import DictFieldData
from xblock.fields import ScopeIds
from xmodule.word_cloud_block import WordCloudBlock
from xmodule import word_cloud_block
from . import get_test_descriptor_system, get_test_system
class WordCloudBlockTest(TestCase):
class _TestWordCloudBase(TestCase):
"""
Logic tests for Word Cloud Block.
"""
__test__ = False
raw_field_data = {
'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2},
'top_words': {'cat': 10, 'dog': 5, 'dad': 2},
'submitted': False,
'display_name': 'Word Cloud Block',
'instructions': 'Enter some random words that comes to your mind'
}
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.word_cloud_class = word_cloud_block.reset_class()
def setUp(self):
super().setUp()
self.raw_field_data = {
'all_words': {'cat': 10, 'dog': 5, 'mom': 1, 'dad': 2},
'top_words': {'cat': 10, 'dog': 5, 'dad': 2},
'submitted': False,
'display_name': 'Word Cloud Block',
'instructions': 'Enter some random words that comes to your mind'
}
def test_xml_import_export_cycle(self):
"""
Test the import export cycle.
"""
runtime = get_test_descriptor_system()
runtime.export_fs = MemoryFS()
@@ -43,7 +53,11 @@ class WordCloudBlockTest(TestCase):
olx_element = etree.fromstring(original_xml)
runtime.id_generator = Mock()
block = WordCloudBlock.parse_xml(olx_element, runtime, None)
def_id = runtime.id_generator.create_definition(olx_element.tag, olx_element.get('url_name'))
keys = ScopeIds(None, olx_element.tag, def_id, runtime.id_generator.create_usage(def_id))
block = self.word_cloud_class.parse_xml(olx_element, runtime, keys)
block.location = BlockUsageLocator(
CourseLocator('org', 'course', 'run', branch='revision'), 'word_cloud', 'block_id'
)
@@ -54,38 +68,70 @@ class WordCloudBlockTest(TestCase):
assert block.num_inputs == 3
assert block.num_top_words == 100
node = etree.Element("unknown_root")
# This will export the olx to a separate file.
block.add_xml_to_node(node)
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# For extracted XBlocks, we need to manually export the XML definition to a file to properly test the
# import/export cycle. This is because extracted XBlocks use XBlock core's `add_xml_to_node` method,
# which does not export the XML to a file like `XmlMixin.add_xml_to_node` does.
filepath = 'word_cloud/block_id.xml'
runtime.export_fs.makedirs(os.path.dirname(filepath), recreate=True)
with runtime.export_fs.open(filepath, 'wb') as fileObj:
runtime.export_to_xml(block, fileObj)
else:
node = etree.Element("unknown_root")
# This will export the olx to a separate file.
block.add_xml_to_node(node)
with runtime.export_fs.open('word_cloud/block_id.xml') as f:
exported_xml = f.read()
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# For extracted XBlocks, we need to remove the `xblock-family` attribute from the exported XML to ensure
# consistency with the original XML.
# This is because extracted XBlocks use the core XBlock's `add_xml_to_node` method, which includes this
# attribute, whereas `XmlMixin.add_xml_to_node` does not.
exported_xml_tree = etree.fromstring(exported_xml.encode('utf-8'))
etree.cleanup_namespaces(exported_xml_tree)
if 'xblock-family' in exported_xml_tree.attrib:
del exported_xml_tree.attrib['xblock-family']
exported_xml = etree.tostring(exported_xml_tree, encoding='unicode', pretty_print=True)
assert exported_xml == original_xml
def test_bad_ajax_request(self):
"""
Make sure that answer for incorrect request is error json.
"""
module_system = get_test_system()
block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock())
block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock())
response = json.loads(block.handle_ajax('bad_dispatch', {}))
self.assertDictEqual(response, {
'status': 'fail',
'error': 'Unknown Command!'
})
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# The extracted Word Cloud XBlock uses @XBlock.json_handler for handling AJAX requests,
# which requires a different way of method invocation.
with self.assertRaises(AttributeError) as context:
json.loads(block.bad_dispatch('bad_dispatch', {}))
self.assertIn("'WordCloudBlock' object has no attribute 'bad_dispatch'", str(context.exception))
else:
response = json.loads(block.handle_ajax('bad_dispatch', {}))
self.assertDictEqual(response, {
'status': 'fail',
'error': 'Unknown Command!'
})
def test_good_ajax_request(self):
"""
Make sure that ajax request works correctly.
"""
module_system = get_test_system()
block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock())
block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock())
post_data = MultiDict(('student_words[]', word) for word in ['cat', 'cat', 'dog', 'sun'])
response = json.loads(block.handle_ajax('submit', post_data))
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# The extracted Word Cloud XBlock uses @XBlock.json_handler for handling AJAX requests.
# It expects a standard Python dictionary as POST data and returns a JSON object in response.
post_data = {'student_words': ['cat', 'cat', 'dog', 'sun']}
response = block.submit_state(post_data)
else:
post_data = MultiDict(('student_words[]', word) for word in ['cat', 'cat', 'dog', 'sun'])
response = json.loads(block.handle_ajax('submit', post_data))
assert response['status'] == 'success'
assert response['submitted'] is True
assert response['total_count'] == 22
@@ -109,9 +155,8 @@ class WordCloudBlockTest(TestCase):
"""
Test indexibility of Word Cloud
"""
module_system = get_test_system()
block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock())
block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock())
assert block.index_dictionary() ==\
{'content_type': 'Word Cloud',
'content': {'display_name': 'Word Cloud Block',
@@ -128,13 +173,23 @@ class WordCloudBlockTest(TestCase):
'num_top_words': 10,
'display_student_percents': 'False',
}
if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK:
# In the extracted Word Cloud XBlock, we use StudioEditableXBlockMixin.submit_studio_edits,
# which expects a different handler name and request JSON format.
handler_name = 'submit_studio_edits'
TEST_REQUEST_JSON = {
'values': TEST_SUBMIT_DATA,
}
else:
handler_name = 'studio_submit'
TEST_REQUEST_JSON = TEST_SUBMIT_DATA
module_system = get_test_system()
block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock())
body = json.dumps(TEST_SUBMIT_DATA)
block = self.word_cloud_class(module_system, DictFieldData(self.raw_field_data), Mock())
body = json.dumps(TEST_REQUEST_JSON)
request = Request.blank('/')
request.method = 'POST'
request.body = body.encode('utf-8')
res = block.handle('studio_submit', request)
res = block.handle(handler_name, request)
assert json.loads(res.body.decode('utf8')) == {'result': 'success'}
assert block.display_name == TEST_SUBMIT_DATA['display_name']
@@ -142,3 +197,13 @@ class WordCloudBlockTest(TestCase):
assert block.num_inputs == TEST_SUBMIT_DATA['num_inputs']
assert block.num_top_words == TEST_SUBMIT_DATA['num_top_words']
assert block.display_student_percents == (TEST_SUBMIT_DATA['display_student_percents'] == "True")
@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=True)
class TestWordCloudExtracted(_TestWordCloudBase):
__test__ = True
@override_settings(USE_EXTRACTED_WORD_CLOUD_BLOCK=False)
class TestWordCloudBuiltIn(_TestWordCloudBase):
__test__ = True

View File

@@ -316,8 +316,17 @@ class _BuiltInWordCloudBlock( # pylint: disable=abstract-method
return xblock_body
WordCloudBlock = (
_ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK
else _BuiltInWordCloudBlock
)
WordCloudBlock = None
def reset_class():
"""Reset class as per django settings flag"""
global WordCloudBlock
WordCloudBlock = (
_ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK
else _BuiltInWordCloudBlock
)
return WordCloudBlock
reset_class()
WordCloudBlock.__name__ = "WordCloudBlock"