diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6e6a027 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.py[cod] +tests.integration.*.log +tests.integration.*.png +vectordraw_xblock.egg-info/ +var/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8e5d815 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: python +python: + - 2.7 +before_install: + - export DISPLAY=:99 + - sh -e /etc/init.d/xvfb start +install: + - pip install -r test-requirements.txt + - pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/base.txt + - pip install -r $VIRTUAL_ENV/src/xblock-sdk/requirements/test.txt + - pip install -r $VIRTUAL_ENV/src/xblock/requirements.txt +script: + - pep8 --max-line-length=100 vectordraw + - pylint vectordraw + - ./run_tests.py --with-coverage --cover-package=vectordraw +notifications: + email: false +addons: + firefox: 36.0 diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..b684e81 --- /dev/null +++ b/pylintrc @@ -0,0 +1,19 @@ +[REPORTS] +reports=no + +[FORMAT] +max-line-length=100 + +[MESSAGES CONTROL] +disable= + I, + attribute-defined-outside-init, + maybe-no-member, + star-args, + too-few-public-methods, + too-many-ancestors, + too-many-instance-attributes, + too-many-public-methods + +[VARIABLES] +dummy-variables-rgx=_$|dummy|unused diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0b4c5d3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +git+https://github.com/edx/XBlock.git@xblock-0.4.2#egg=XBlock +git+https://github.com/edx/xblock-utils.git@b4f9b51146c7fafa12f41d54af752b8f1516dffd#egg=xblock-utils +-e . diff --git a/run_tests.py b/run_tests.py new file mode 100755 index 0000000..3aae2d6 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Run tests for the Vector Drawing XBlock. + +This script is required to run our selenium tests inside the xblock-sdk workbench +because the workbench SDK's settings file is not inside any python module. +""" + +import os +import logging +import sys + +from django.conf import settings +from django.core.management import execute_from_command_line + +logging_level_overrides = { + 'workbench.views': logging.ERROR, + 'django.request': logging.ERROR, + 'workbench.runtime': logging.ERROR, +} + +if __name__ == '__main__': + # Use the workbench settings file: + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'workbench.settings') + # Configure a range of ports in case the default port of 8081 is in use + os.environ.setdefault('DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:8081-8099') + + try: + os.mkdir('var') + except OSError: + # May already exist. + pass + + settings.INSTALLED_APPS += ('vectordraw', ) + + for noisy_logger, log_level in logging_level_overrides.iteritems(): + logging.getLogger(noisy_logger).setLevel(log_level) + + args_iter = iter(sys.argv[1:]) + options = [] + paths = [] + for arg in args_iter: + if arg == '--': + break + if arg.startswith('-'): + options.append(arg) + else: + paths.append(arg) + paths.extend(args_iter) + if not paths: + paths = ['tests/'] + execute_from_command_line([sys.argv[0], 'test'] + options + ['--'] + paths) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5491950 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +"""Setup for vectordraw XBlock.""" + +import os +from setuptools import setup + + +def package_data(pkg, roots): + """Generic function to find package_data. + + All of the files under each of the `roots` will be declared as package + data for package `pkg`. + + """ + data = [] + for root in roots: + for dirname, _, files in os.walk(os.path.join(pkg, root)): + for fname in files: + data.append(os.path.relpath(os.path.join(dirname, fname), pkg)) + + return {pkg: data} + + +setup( + name='vectordraw-xblock', + version='0.1', + description='vectordraw XBlock', # TODO: write a better description. + packages=[ + 'vectordraw', + ], + install_requires=[ + 'XBlock', + 'xblock-utils', + ], + entry_points={ + 'xblock.v1': [ + 'vectordraw = vectordraw:VectorDrawXBlock', + ] + }, + package_data=package_data("vectordraw", ["static", "public"]), +) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..7fcdbc3 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +Django>=1.8, <1.9 +-r requirements.txt +-e git+https://github.com/edx/xblock-sdk.git@8eb5f174dc59c0f4e40e10eaab56753958651d17#egg=xblock-sdk +ddt +selenium==2.47.3 # 2.48 is not working atm diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_vectordraw.py b/tests/integration/test_vectordraw.py new file mode 100644 index 0000000..889de4b --- /dev/null +++ b/tests/integration/test_vectordraw.py @@ -0,0 +1,1034 @@ +import json + +from ddt import ddt, data +from selenium.common.exceptions import NoSuchElementException + +from xblockutils.resources import ResourceLoader +from xblockutils.studio_editable_test import StudioEditableBaseTest + +loader = ResourceLoader(__name__) # pylint: disable=invalid-name + + +@ddt +class TestVectorDraw(StudioEditableBaseTest): + """ + Test student view of VectorDrawXBlock. + """ + + def load_scenario(self, path, params=None): + scenario = loader.render_template(path, params) + self.set_scenario_xml(scenario) + self.element = self.go_to_view("student_view") + self.exercise = self.element.find_element_by_css_selector(".vectordraw_block") + + def assert_not_present(self, parent, selector, errmsg): + try: + parent.find_element_by_css_selector(selector) + except NoSuchElementException: + pass + else: + self.fail(errmsg) + + def assert_hidden_text(self, selector, expected_text): + hidden_text = self.browser.execute_script("return $('{}').text();".format(selector)) + self.assertEquals(hidden_text, expected_text) + + def assert_title_and_description(self, expected_title="Vector Drawing", expected_description=None): + title = self.exercise.find_element_by_css_selector("h2") + self.assertEquals(title.text, expected_title) + if expected_description: + description = self.exercise.find_element_by_css_selector(".vectordraw-description") + self.assertEquals(description.text, expected_description) + else: + self.assert_not_present( + self.exercise, + ".vectordraw-description", + "Description element present even though no description has been set by user." + ) + + def assert_dimensions(self, board, expected_width="550px", expected_height="400px"): + width = board.value_of_css_property("width") + height = board.value_of_css_property("height") + self.assertEquals(width, expected_width) + self.assertEquals(height, expected_height) + + def assert_axis(self, board, is_present=False): + text_elements = board.find_elements_by_css_selector(".JXGtext") + ticks = any("ticks" in text_element.get_attribute("id") for text_element in text_elements) + self.assertEquals(ticks, is_present) + + def assert_navigation_bar(self, board, is_present=False): + if is_present: + navigation_bar = board.find_element_by_css_selector("#jxgboard1_navigationbar") + self.assertTrue(navigation_bar.is_displayed()) + else: + self.assert_not_present( + board, + "#jxgboard1_navigationbar", + "Navigation bar should be hidden by default." + ) + + def assert_background(self, board, is_present=False): + if is_present: + background = board.find_element_by_css_selector("image") + self.assertTrue(background.is_displayed()) + src = background.get_attribute("xlink:href") + self.assertEquals(src, "https://github.com/open-craft/jsinput-vectordraw/raw/master/Notes_and_Examples/2_boxIncline_multiVector/box_on_incline.png") + alt = background.get_attribute("alt") + self.assertEquals(alt, "A very informative description") + else: + self.assert_not_present( + board, + "image", + "Board should not contain background image by default." + ) + + def assert_buttons(self, controls, add_vector_label="Add Selected Force"): + # "Add vector" button + add_vector = controls.find_element_by_css_selector(".add-vector") + self.assertEquals(add_vector.text, add_vector_label) + # "Reset" button + reset = controls.find_element_by_css_selector(".reset") + reset_label = reset.find_element_by_css_selector('.reset-label') + self.assertEquals(reset_label.text, "Reset") + reset.find_element_by_css_selector(".sr") + self.assert_hidden_text(".reset > .sr", "Reset board to initial state") + # "Redo" button + redo = controls.find_element_by_css_selector(".redo") + redo.find_element_by_css_selector(".fa.fa-repeat") + redo.find_element_by_css_selector(".sr") + self.assert_hidden_text(".redo > .sr", "Redo last action") + # "Undo" button + undo = controls.find_element_by_css_selector(".undo") + undo.find_element_by_css_selector(".fa.fa-undo") + undo.find_element_by_css_selector(".sr") + self.assert_hidden_text(".undo > .sr", "Undo last action") + + def assert_vector_properties( + self, menu, is_present=False, expected_label="Vector Properties", + expected_name=None, expected_tail=None, expected_length=None, expected_angle=None, + input_fields_disabled=True + ): + if is_present: + vector_properties = menu.find_element_by_css_selector(".vector-properties") + vector_properties_label = vector_properties.find_element_by_css_selector("h3") + self.assertEquals(vector_properties_label.text, expected_label) + # Name + self.assert_vector_property( + vector_properties, "name", "select", "name:", expected_name or "-", + field_disabled=input_fields_disabled + ) + # Tail + self.assert_vector_property( + vector_properties, "tail", "input", "tail position:", expected_tail or "", + field_disabled=input_fields_disabled + ) + # Length + self.assert_vector_property( + vector_properties, "length", "input", "length:", expected_length or "", + field_disabled=input_fields_disabled + ) + # Angle + self.assert_vector_property( + vector_properties, "angle", "input", "angle:", expected_angle or "", + field_disabled=input_fields_disabled + ) + # Slope + vector_slope = vector_properties.find_element_by_css_selector(".vector-prop-slope") + self.assertFalse(vector_slope.is_displayed()) + # "Update" button + update_button = vector_properties.find_element_by_css_selector('button.update') + update_button_disabled = update_button.get_attribute('disabled') + self.assertEquals(bool(update_button_disabled), input_fields_disabled) + else: + self.assert_not_present( + menu, + ".vector-properties", + "If show_vector_properties is set to False, menu should not show vector properties." + ) + + def assert_vector_property( + self, vector_properties, property_name, input_type, expected_label, expected_value=None, + field_disabled=False + ): + vector_property = vector_properties.find_element_by_css_selector( + ".vector-prop-{}".format(property_name) + ) + vector_property_label = vector_property.find_element_by_css_selector( + "#vector-prop-{}-label".format(property_name) + ) + self.assertEquals(vector_property_label.text, expected_label) + vector_property_input = vector_property.find_element_by_css_selector(input_type) + self.assertEquals( + vector_property_input.get_attribute("aria-labelledby"), "vector-prop-{}-label".format(property_name) + ) + if input_type == "input": + self.assertEquals(vector_property_input.get_attribute("value"), expected_value) + disabled = vector_property_input.get_attribute("disabled") + self.assertEquals(bool(disabled), field_disabled) + else: + selected_option = vector_property_input.find_element_by_css_selector('option[selected="selected"]') + self.assertEquals(selected_option.text, expected_value) + + def assert_actions(self): + actions = self.exercise.find_element_by_css_selector(".action") + self.assertTrue(actions.is_displayed()) + check = actions.find_element_by_css_selector(".check") + check_label = check.find_element_by_css_selector(".check-label") + self.assertEquals(check_label.text, "CHECK") + check.find_element_by_css_selector(".sr") + self.assert_hidden_text(".check > .sr", "Check your answer") + + def assert_add_dropdown(self, controls, vectors=[], points=[]): + # Check dropdown + dropdown = controls.find_element_by_css_selector("select") + if not vectors and not points: + self.assert_not_present( + dropdown, + "option", + "Dropdown should not list any vectors or points by default." + ) + else: + self.assert_add_options(dropdown, vectors, "vector") + non_fixed_points = [point for point in points if not point["fixed"]] + self.assert_add_options(dropdown, non_fixed_points, "point") + # Check label + label_selector = "label.sr" + select_label = controls.find_element_by_css_selector(label_selector) + self.assert_hidden_text(label_selector, "Select element to add to board") + select_id = "element-list" + self.assertEquals(select_label.get_attribute("for"), select_id) + + def assert_add_options(self, dropdown, elements, element_type): + element_options = dropdown.find_elements_by_css_selector('option[value^="{}-"]'.format(element_type)) + self.assertEquals(len(element_options), len(elements)) + for element, element_option in zip(elements, element_options): + self.assertEquals(element_option.text, element["description"]) + option_disabled = element_option.get_attribute("disabled") + self.assertEquals(bool(option_disabled), element["render"]) + + def assert_edit_dropdown(self, menu, vectors=[], points=[]): + vector_properties = menu.find_element_by_css_selector(".vector-properties") + # Check dropdown + dropdown = vector_properties.find_element_by_css_selector("select") + if not vectors and not points: + options = dropdown.find_elements_by_css_selector("option") + self.assertEquals(len(options), 1) + default_option = options[0] + self.assertEquals(default_option.get_attribute("value"), "-") + else: + if vectors: + self.assert_edit_options(dropdown, vectors, "vector") + if points: + non_fixed_points = [point for point in points if not point["fixed"]] + self.assert_edit_options(dropdown, non_fixed_points, "point") + + def assert_edit_options(self, dropdown, elements, element_type): + element_options = dropdown.find_elements_by_css_selector('option[value^="{}-"]'.format(element_type)) + self.assertEquals(len(element_options), len(elements)) + for element, element_option in zip(elements, element_options): + self.assertEquals(element_option.text, element["name"]) + option_disabled = element_option.get_attribute("disabled") + self.assertNotEquals(bool(option_disabled), element["render"]) + + def assert_vectors(self, board, vectors): + line_elements = board.find_elements_by_css_selector("line") + point_elements = board.find_elements_by_css_selector("ellipse") + for vector in vectors: + # Find line + board_has_line = self.board_has_line(vector["expected_line_position"], line_elements) + # Find tail + board_has_tail = self.board_has_point(vector["expected_tail_position"], point_elements) + # Find tip + board_has_tip = self.board_has_point(vector["expected_tip_position"], point_elements) + # Find label + board_has_label = self.board_has_label(board, vector["name"]) + # Check if line, tip, tail are present + if vector["render"]: + self.assertTrue(board_has_line) + self.assertTrue(board_has_tail) + self.assertTrue(board_has_tip) + self.assertTrue(board_has_label) + else: + self.assertFalse(board_has_line) + self.assertFalse(board_has_tail) + self.assertFalse(board_has_tip) + self.assertFalse(board_has_label) + + def assert_points(self, board, points): + point_elements = board.find_elements_by_css_selector("ellipse") + for point in points: + board_has_point = self.board_has_point(point["expected_position"], point_elements) + self.assertEquals(board_has_point, point["render"]) + + def board_has_line(self, position, line_elements): + line = self.find_line(position, line_elements) + return bool(line) and self.line_has_title(line) and self.line_has_desc(line) + + def board_has_point(self, position, point_elements): + return bool(self.find_point(position, point_elements)) + + def board_has_label(self, board, label_text): + text_elements = board.find_elements_by_css_selector(".JXGtext") + for text_element in text_elements: + is_tick = "ticks" in text_element.get_attribute("id") + if not is_tick and text_element.text == label_text: + return True + return False + + def line_has_title(self, line): + title = line.find_element_by_css_selector("title") + title_id = title.get_attribute("id") + aria_labelledby = line.get_attribute("aria-labelledby") + return title_id == aria_labelledby + + def line_has_desc(self, line): + aria_describedby = line.get_attribute("aria-describedby") + return aria_describedby == "jxgboard1-vector-properties" + + def find_line(self, position, line_elements): + expected_line_position = position.items() + for line in line_elements: + line_position = { + "x1": int(line.get_attribute("x1").split(".", 1)[0]), + "y1": int(line.get_attribute("y1").split(".", 1)[0]), + "x2": int(line.get_attribute("x2").split(".", 1)[0]), + "y2": int(line.get_attribute("y2").split(".", 1)[0]), + }.items() + if line_position == expected_line_position: + return line + + def find_point(self, position, point_elements): + expected_position = position.items() + for point in point_elements: + point_position = { + "cx": int(point.get_attribute("cx").split(".", 1)[0]), + "cy": int(point.get_attribute("cy").split(".", 1)[0]), + }.items() + if point_position == expected_position: + return point + + def add_vector(self, board, vectors): + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + add_vector = controls.find_element_by_css_selector(".add-vector") + add_vector.click() + # Board should now show vector + vectors[0]["render"] = True + self.assert_vectors(board, vectors) + # "Vector Properties" should display correct info + self.assert_vector_properties( + menu, is_present=True, expected_label="Custom properties label", + expected_name="N", expected_tail="2.00, 2.00", expected_length="4.00", expected_angle="45.00", + input_fields_disabled=False + ) + self.assert_edit_dropdown(menu, vectors) + + def add_point(self, board, points): + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + add_vector = controls.find_element_by_css_selector(".add-vector") + add_vector.click() + # Board should now show point + points[0]["render"] = True + self.assert_points(board, points) + + def undo(self, board, vectors): + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + undo = controls.find_element_by_css_selector(".undo") + undo.click() + # Board should not show vector anymore + vectors[0]["render"] = False + self.assert_vectors(board, vectors) + + def redo(self, board, vectors): + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + redo = controls.find_element_by_css_selector(".redo") + redo.click() + # Board should now show vector + vectors[0]["render"] = True + self.assert_vectors(board, vectors) + # "Vector Properties" should display correct info + self.assert_vector_properties( + menu, is_present=True, expected_label="Custom properties label", + expected_name="N", expected_tail="2.00, 2.00", expected_length="4.00", expected_angle="45.00", + input_fields_disabled=False + ) + + def reset(self, board, vectors, points): + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + reset = controls.find_element_by_css_selector(".reset") + reset.click() + # Board should not show vector anymore + vectors[0]["render"] = False + self.assert_vectors(board, vectors) + # Board should not show point anymore + points[0]["render"] = False + self.assert_points(board, points) + + def submit_answer(self): + actions = self.exercise.find_element_by_css_selector(".action") + check = actions.find_element_by_css_selector(".check") + check.click() + + def assert_status(self, answer_correct=True, expected_message="Test passed"): + status = self.exercise.find_element_by_css_selector(".vectordraw-status") + self.assertTrue(status.is_displayed()) + correctness = status.find_element_by_css_selector(".correctness") + if answer_correct: + self.assertIn("checkmark-correct fa fa-check", correctness.get_attribute("class")) + else: + self.assertIn("checkmark-incorrect fa fa-times", correctness.get_attribute("class")) + status_message = status.find_element_by_css_selector(".status-message") + self.assertEquals(status_message.text, expected_message) + + def change_property(self, property_name, new_value): + menu = self.exercise.find_element_by_css_selector(".menu") + vector_properties = menu.find_element_by_css_selector(".vector-properties") + vector_property = vector_properties.find_element_by_css_selector( + ".vector-prop-{}".format(property_name) + ) + vector_property_input = vector_property.find_element_by_css_selector("input") + # Enter new value + vector_property_input.clear() + vector_property_input.send_keys(new_value) + # Find "Update" button + update_button = vector_properties.find_element_by_css_selector(".vector-prop-update") + # Click "Update" button + update_button.click() + + def test_defaults(self): + self.load_scenario("xml/defaults.xml") + + # Check title and description + self.assert_title_and_description() + + # Check board + board = self.exercise.find_element_by_css_selector("#jxgboard1") + self.assert_dimensions(board) + self.assert_axis(board, is_present=True) + self.assert_navigation_bar(board) + self.assert_background(board) + # - Vectors + self.assert_not_present( + board, + "line[aria-labelledby]", # axes (which are present by default) don't have aria-labelledby attribute + "Board should not contain any vectors or lines by default." + ) + # - Points + self.assert_not_present( + board, + "ellipse:not([display])", # points don't have in-line "display" property + "Board should not contain any points by default." + ) + + # Check menu + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + self.assert_add_dropdown(controls) + self.assert_buttons(controls) + self.assert_vector_properties(menu, is_present=True) + self.assert_edit_dropdown(menu) + + # Check actions + self.assert_actions() + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": True, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + }, { + "name": "f", + "description": "Friction - f", + "coords": [ + [-2, -5], + [-1, -3] + ], + "render": False, + "expected_line_position": {"x1": 257, "y1": 340, "x2": 273, "y2": 304}, + "expected_tail_position": {"cx": 257, "cy": 340}, + "expected_tip_position": {"cx": 279, "cy": 294}, + } + ]), + "points": json.dumps([ + { + "name": "cmA", + "description": "Point A", + "coords": [-0.1, -2.2], + "render": True, + "fixed": True, + "expected_position": {"cx": 300, "cy": 276}, + }, + { + "name": "cmB", + "description": "Point B", + "coords": [-4.0, 0.21], + "render": True, + "fixed": False, + "expected_position": {"cx": 211, "cy": 222}, + }, + { + "name": "cmC", + "description": "Point C", + "coords": [2.5, 2.9], + "render": False, + "fixed": False, + "expected_position": {"cx": 359, "cy": 161}, + } + ]), + "expected_result": json.dumps({}) + }, + { + "show_vector_properties": False, + "vectors": json.dumps([]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + }, + ) + def test_custom_exercise(self, params): + vectors = json.loads(params["vectors"]) + points = json.loads(params["points"]) + self.load_scenario("xml/custom.xml", params=params) + + # Check title and description + self.assert_title_and_description( + expected_title="Custom Exercise", expected_description="Custom exercise description" + ) + + # Check board + board = self.exercise.find_element_by_css_selector("#jxgboard1") + self.assert_dimensions(board, expected_width="600px", expected_height="450px") + self.assert_axis(board) + self.assert_navigation_bar(board, is_present=True) + self.assert_background(board, is_present=True) + # - Vectors + self.assert_vectors(board, vectors) + # - Points + self.assert_points(board, points) + + # Check menu + menu = self.exercise.find_element_by_css_selector(".menu") + controls = menu.find_element_by_css_selector(".controls") + self.assert_add_dropdown(controls, vectors, points) + self.assert_buttons(controls, add_vector_label="Custom button label") + show_vector_properties = params["show_vector_properties"] + if show_vector_properties: + self.assert_vector_properties(menu, is_present=True, expected_label="Custom properties label") + self.assert_edit_dropdown(menu, vectors, points) + else: + self.assert_vector_properties(menu) + + # Check actions + self.assert_actions() + + @data("line", "tail", "tip") + def test_select_vector(self, click_target): + params = { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": True, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + vector = json.loads(params["vectors"])[0] + if click_target == "line": + # Find line and click it + line_elements = board.find_elements_by_css_selector("line") + line = self.find_line(vector["expected_line_position"], line_elements) + line.click() + elif click_target == "tail": + # Find tail and click it + point_elements = board.find_elements_by_css_selector("ellipse") + tail = self.find_point(vector["expected_tail_position"], point_elements) + tail.click() + else: + # Find tip and click it + point_elements = board.find_elements_by_css_selector("ellipse") + tip = self.find_point(vector["expected_tip_position"], point_elements) + tip.click() + # Check if "Vector Properties" shows correct info + menu = self.exercise.find_element_by_css_selector(".menu") + self.assert_vector_properties( + menu, is_present=True, expected_label="Custom properties label", + expected_name="N", expected_tail="2.00, 2.00", expected_length="4.00", expected_angle="45.00", + input_fields_disabled=False + ) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_add_vector(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([]), + "points": json.dumps([ + { + "name": "cmC", + "description": "Point C", + "coords": [2.5, 2.9], + "render": False, + "fixed": False, + "expected_position": {"cx": 359, "cy": 161}, + } + ]), + "expected_result": json.dumps({}) + } + ) + def test_add_point(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show point initially + points = json.loads(params["points"]) + self.assert_points(board, points) + # Add point + self.add_point(board, points) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_undo(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Undo + self.undo(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_redo(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Undo + self.undo(board, vectors) + # Redo + self.redo(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([ + { + "name": "cmC", + "description": "Point C", + "coords": [2.5, 2.9], + "render": False, + "fixed": False, + "expected_position": {"cx": 359, "cy": 161}, + } + ]), + "expected_result": json.dumps({}) + } + ) + def test_reset(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Board should not show point initially + points = json.loads(params["points"]) + self.assert_points(board, points) + # Add vector + self.add_vector(board, vectors) + # Add point + self.add_point(board, points) + # Reset + self.reset(board, vectors, points) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({ + "N": {"angle": 45, "tail": [2, 2]}, + }) + } + ) + def test_correct_answer(self, params): + # Logic for checking answer is covered by unit tests; + # we are only checking UI behavior here. + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + vectors = json.loads(params["vectors"]) + # Add vector + self.add_vector(board, vectors) + # Submit answer + self.submit_answer() + # Check status + self.assert_status() + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({ + "N": {"angle": 110, "tail": [-0.6, 0.4]}, + }) + } + ) + def test_incorrect_answer(self, params): + # Logic for checking answer is covered by unit tests; + # we are only checking UI behavior here. + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + vectors = json.loads(params["vectors"]) + # Add vector + self.add_vector(board, vectors) + # Submit answer + self.submit_answer() + # Check status + self.assert_status( + answer_correct=False, expected_message="Vector N does not start at correct point." + ) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({ + "N": {"angle": 45, "tail": [2, 2]}, + }), + "answer_correct": True + }, + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({ + "N": {"angle": 110, "tail": [-0.6, 0.4]}, + }), + "answer_correct": False + } + ) + def test_state(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + vectors = json.loads(params["vectors"]) + # Board should not show vector initially + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Submit answer + self.submit_answer() + # Reload page + self.element = self.go_to_view("student_view") + self.exercise = self.element.find_element_by_css_selector(".vectordraw_block") + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should show vector + vectors[0]["render"] = True + self.assert_vectors(board, vectors) + # Status should show last result + if params["answer_correct"]: + self.assert_status() + else: + self.assert_status( + answer_correct=False, expected_message="Vector N does not start at correct point." + ) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_change_tail_property(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Change tail + self.change_property("tail", "3, 3") + # Check new position: Tail updated, tip updated + vectors[0]["expected_line_position"] = {'x1': 370, 'y1': 159, 'x2': 425, 'y2': 102} + vectors[0]["expected_tail_position"] = {'cx': 369, 'cy': 158} + vectors[0]["expected_tip_position"] = {'cx': 434, 'cy': 94} + self.assert_vectors(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_change_length_property(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Change tail + self.change_property("length", "6") + # Check new position: Tail unchanged, tip updated + vectors[0]["expected_line_position"] = {'x1': 347, 'y1': 181, 'x2': 434, 'y2': 93} + vectors[0]["expected_tail_position"] = {'cx': 347, 'cy': 181} + vectors[0]["expected_tip_position"] = {'cx': 443, 'cy': 85} + self.assert_vectors(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_change_angle_property(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Change tail + self.change_property("angle", "170") + # Check new position: Tail unchanged, tip updated + vectors[0]["expected_line_position"] = {'x1': 347, 'y1': 181, 'x2': 269, 'y2': 167} + vectors[0]["expected_tail_position"] = {'cx': 347, 'cy': 181} + vectors[0]["expected_tip_position"] = {'cx': 258, 'cy': 165} + self.assert_vectors(board, vectors) + + @data( + { + "show_vector_properties": True, + "vectors": json.dumps([ + { + "name": "N", + "description": "Normal force - N", + "tail": [2, 2], + "length": 4, + "angle": 45, + "render": False, + "expected_line_position": {"x1": 347, "y1": 181, "x2": 402, "y2": 125}, + "expected_tail_position": {"cx": 347, "cy": 181}, + "expected_tip_position": {"cx": 411, "cy": 117}, + } + ]), + "points": json.dumps([]), + "expected_result": json.dumps({}) + } + ) + def test_change_property_invalid_input(self, params): + self.load_scenario("xml/custom.xml", params=params) + board = self.exercise.find_element_by_css_selector("#jxgboard1") + # Board should not show vector initially + vectors = json.loads(params["vectors"]) + self.assert_vectors(board, vectors) + # Add vector + self.add_vector(board, vectors) + # Change tail + self.change_property("tail", "invalid") + # Check new position: Tail unchanged, tip unchanged + vectors[0]["expected_line_position"] = {'x1': 347, 'y1': 181, 'x2': 402, 'y2': 125} + vectors[0]["expected_tail_position"] = {'cx': 347, 'cy': 181} + vectors[0]["expected_tip_position"] = {'cx': 411, 'cy': 117} + self.assert_vectors(board, vectors) + # Check error message + error_message = self.exercise.find_element_by_css_selector(".update-error"); + self.wait_until_visible(error_message) + self.assertEquals(error_message.text, "Invalid input.") diff --git a/tests/integration/xml/custom.xml b/tests/integration/xml/custom.xml new file mode 100644 index 0000000..5dacc5e --- /dev/null +++ b/tests/integration/xml/custom.xml @@ -0,0 +1,20 @@ + + + diff --git a/tests/integration/xml/defaults.xml b/tests/integration/xml/defaults.xml new file mode 100644 index 0000000..54a9c10 --- /dev/null +++ b/tests/integration/xml/defaults.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_vectordraw.py b/tests/unit/test_vectordraw.py new file mode 100644 index 0000000..2d50749 --- /dev/null +++ b/tests/unit/test_vectordraw.py @@ -0,0 +1,241 @@ +import unittest +from vectordraw import grader +from vectordraw.grader import Vector + + +class VectorDrawTest(unittest.TestCase): + + def __init__(self, *args, **kwargs): + super(VectorDrawTest, self).__init__(*args, **kwargs) + + # Helpers + + def check(self, expected=None, tolerance=1.0, vector='vec', errmsg=None): + check = {'vector': vector, 'expected': expected, 'tolerance': tolerance} + if errmsg: + check['errmsg'] = errmsg + return check + + def vector(self, x1=0, y1=0, x2=1, y2=1, name='vec'): + return Vector(name, x1, y1, x2, y2) + + def assertPasses(self, check_function, check, vectors): + try: + check_function(check, vectors) + except ValueError: + self.fail('{check_function} should not raise an error for check {check} and vectors {vectors}.'.format( + check_function=check_function.__name__, check=check, vectors=vectors + )) + + def assertFails(self, check_function, check, vectors, error_message): + try: + check_function(check, vectors) + except ValueError as e: + self.assertEquals(e.message, error_message) + else: + self.fail('{check_function} should raise an error for check {check} and vectors {vectors}.'.format( + check_function=check_function.__name__, check=check, vectors=vectors + )) + + # Test built-in checks + + def test_check_presence(self): + errmsg = 'You need to use the othervec vector.' + vectors = {'myvec': self.vector(name='myvec')} + self.assertPasses(grader.check_presence, self.check(vector='myvec'), vectors) + self.assertFails(grader.check_presence, self.check(vector='othervec'), vectors, errmsg) + custom_errmsg = 'Use {name}, please!' + self.assertFails( + grader.check_presence, self.check(vector='vec X', errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec X') + ) + + def test_check_tail(self): + errmsg = 'Vector vec does not start at correct point.' + vectors = {'vec': self.vector(3, 3, 4, 4)} + self.assertPasses(grader.check_tail, self.check([3, 3], 0), vectors) + self.assertPasses(grader.check_tail, self.check([4, 4], 1.5), vectors) + self.assertFails(grader.check_tail, self.check([3.1, 3], 0), vectors, errmsg) + self.assertFails(grader.check_tail, self.check([4, 4], 1.0), vectors, errmsg) + custom_errmsg = 'Bad start point: [{tail_x}, {tail_y}]' + self.assertFails( + grader.check_tail, self.check([1, 2], errmsg=custom_errmsg), vectors, + custom_errmsg.format(tail_x=3, tail_y=3) + ) + + def test_check_tip(self): + errmsg = 'Vector vec does not end at correct point.' + vectors = {'vec': self.vector(3, 3, 4, 4)} + self.assertPasses(grader.check_tip, self.check([4, 4], 0), vectors) + self.assertPasses(grader.check_tip, self.check([3, 3], 1.5), vectors) + self.assertFails(grader.check_tip, self.check([4.1, 4], 0), vectors, errmsg) + self.assertFails(grader.check_tip, self.check([3, 3], 1.0), vectors, errmsg) + custom_errmsg = '{name} does not start at correct point.' + self.assertFails( + grader.check_tip, self.check([3, 3], errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec') + ) + + def test_check_tail_x(self): + errmsg = 'Vector vec does not start at correct point.' + vectors = {'vec': self.vector(3, 12, 4, 40)} + self.assertPasses(grader.check_tail_x, self.check(3, 0), vectors) + self.assertPasses(grader.check_tail_x, self.check(4, 1), vectors) + self.assertFails(grader.check_tail_x, self.check(5, 0), vectors, errmsg) + self.assertFails(grader.check_tail_x, self.check(5, 1.5), vectors, errmsg) + custom_errmsg = 'Bad starting point for {name}.' + self.assertFails( + grader.check_tail_x, self.check(5, 1.5, errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec') + ) + + def test_check_tail_y(self): + errmsg = 'Vector vec does not start at correct point.' + vectors = {'vec': self.vector(3, 12, 4, 40)} + self.assertPasses(grader.check_tail_y, self.check(12, 0), vectors) + self.assertPasses(grader.check_tail_y, self.check(13, 1), vectors) + self.assertFails(grader.check_tail_y, self.check(13, 0), vectors, errmsg) + self.assertFails(grader.check_tail_y, self.check(10, 1.5), vectors, errmsg) + custom_errmsg = 'Tail y should not be {tail_y:.1f}.' + self.assertFails( + grader.check_tail_y, self.check(10, 1.5, errmsg=custom_errmsg), vectors, + custom_errmsg.format(tail_y=12) + ) + + def test_check_tip_x(self): + errmsg = 'Vector vec does not end at correct point.' + vectors = {'vec': self.vector(3, 12, 4, 40)} + self.assertPasses(grader.check_tip_x, self.check(4, 0), vectors) + self.assertPasses(grader.check_tip_x, self.check(5, 1), vectors) + self.assertFails(grader.check_tip_x, self.check(5, 0), vectors, errmsg) + self.assertFails(grader.check_tip_x, self.check(2, 1.5), vectors, errmsg) + custom_errmsg = 'Adjust the x tip coordinate.' + self.assertFails( + grader.check_tip_x, self.check(2, 1.5, errmsg=custom_errmsg), vectors, + custom_errmsg + ) + + def test_check_tip_y(self): + errmsg = 'Vector vec does not end at correct point.' + vectors = {'vec': self.vector(3, 12, 4, 40)} + self.assertPasses(grader.check_tip_y, self.check(40, 0), vectors) + self.assertPasses(grader.check_tip_y, self.check(33, 10), vectors) + self.assertFails(grader.check_tip_y, self.check(41, 0), vectors, errmsg) + self.assertFails(grader.check_tip_y, self.check(29, 10), vectors, errmsg) + custom_errmsg = 'Adjust the y tip coordinate of {name}.' + self.assertFails( + grader.check_tip_y, self.check(29, 10, errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec') + ) + + def test_check_coords(self): + errmsg = 'Vector vec coordinates are not correct.' + vectors = {'vec': self.vector(1, 2, 3, 4)} + self.assertPasses(grader.check_coords, self.check([[1, 2], [3, 4]], 0), vectors) + self.assertPasses(grader.check_coords, self.check([[1, 3], [4, 4]], 2), vectors) + self.assertPasses(grader.check_coords, self.check([['_', 2], [3, '_']], 0), vectors) + self.assertPasses(grader.check_coords, self.check([[3, '_'], ['_', 5]], 2), vectors) + self.assertPasses(grader.check_coords, self.check([['_', '_'], ['_', 3]], 2), vectors) + self.assertPasses(grader.check_coords, self.check([['_', '_'], ['_', '_']], 0), vectors) + self.assertFails(grader.check_coords, self.check([[2, 1], [3, 4]], 0), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([[3, 4], [1, 2]], 0), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([[1, 2], [4, 3]], 1), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([[3, 4], [1, 2]], 0), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([['_', 5], [3, 4]], 1), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([['_', 2], [1, '_']], 1), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([['_', 4], [2, '_']], 1), vectors, errmsg) + self.assertFails(grader.check_coords, self.check([['_', 4], ['_', '_']], 1), vectors, errmsg) + custom_errmsg = 'Wrong coordinates: [{tail_x:.1f},{tail_y:.1f}, {tip_x:.1f},{tip_y:.1f}]' + self.assertFails( + grader.check_coords, self.check([['_', '_'], ['_', -4]], errmsg=custom_errmsg), vectors, + custom_errmsg.format(tail_x=1, tail_y=2, tip_x=3, tip_y=4) + ) + + def test_check_segment_coords(self): + errmsg = 'Segment vec coordinates are not correct.' + vectors = {'vec': self.vector(1, 2, 3, 4)} + self.assertPasses(grader.check_segment_coords, self.check([[1, 3], [4, 4]], 2), vectors) + self.assertPasses(grader.check_segment_coords, self.check([[4, 4], [1, 3]], 2), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', 2], [3, '_']], 0), vectors) + self.assertPasses(grader.check_segment_coords, self.check([[3, '_'], ['_', 2]], 0), vectors) + self.assertPasses(grader.check_segment_coords, self.check([[3, '_'], ['_', 5]], 2), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', 5], [3, '_']], 2), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', '_'], ['_', 3]], 2), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', '_'], ['_', '_']], 0), vectors) + self.assertPasses(grader.check_segment_coords, self.check([[1, 2], [3, 4]], 0), vectors) + self.assertPasses(grader.check_segment_coords, self.check([[3, 4], [1, 2]], 0), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', 4], ['_', '_']], 1), vectors) + self.assertPasses(grader.check_segment_coords, self.check([['_', 4], [2, '_']], 1), vectors) + self.assertFails(grader.check_segment_coords, self.check([[2, 1], [3, 4]], 0), vectors, errmsg) + self.assertFails(grader.check_segment_coords, self.check([[-1, -2], [-3, -4]], 0), vectors, errmsg) + self.assertFails(grader.check_segment_coords, self.check([[1, 2], [4, 3]], 1), vectors, errmsg) + self.assertFails(grader.check_segment_coords, self.check([['_', 5], [3, 4]], 1), vectors, errmsg) + self.assertFails(grader.check_segment_coords, self.check([['_', 2], [1, '_']], 1), vectors, errmsg) + self.assertFails(grader.check_segment_coords, self.check([['_', '_'], ['_', 5.5]], 1), vectors, errmsg) + custom_errmsg = 'Coordinates of {name} are wrong!' + self.assertFails( + grader.check_segment_coords, self.check([['_', '_'], ['_', 5.5]], errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec') + ) + + def test_check_length(self): + errmsg = 'The length of vec is incorrect. Your length: 5.0' + vectors = {'vec': self.vector(0, 0, 3, 4)} + self.assertPasses(grader.check_length, self.check(5, 0), vectors) + self.assertPasses(grader.check_length, self.check(7, 2.5), vectors) + self.assertFails(grader.check_length, self.check(4.5, 0), vectors, errmsg) + self.assertFails(grader.check_length, self.check(5.5, 0.25), vectors, errmsg) + custom_errmsg = 'Bad length of {length:.2f}' + self.assertFails( + grader.check_length, self.check(5.5, 0.25, errmsg=custom_errmsg), vectors, + custom_errmsg.format(length=5.00) + ) + + def test_check_angle(self): + errmsg = 'The angle of vec is incorrect. Your angle: 45.0' + vectors = {'vec': self.vector(1, 1, 5, 5)} + self.assertPasses(grader.check_angle, self.check(45, 0.1), vectors) + self.assertPasses(grader.check_angle, self.check(-315, 0.1), vectors) + self.assertPasses(grader.check_angle, self.check(405, 0.1), vectors) + self.assertPasses(grader.check_angle, self.check(-5, 55), vectors) + self.assertPasses(grader.check_angle, self.check(42, 5), vectors) + self.assertFails(grader.check_angle, self.check(315, 0.1), vectors, errmsg) + self.assertFails(grader.check_angle, self.check(30, 9), vectors, errmsg) + custom_errmsg = 'Adjust angle of {name}. Currently: {angle:.0f}.' + self.assertFails( + grader.check_angle, self.check(30, 9, errmsg=custom_errmsg), vectors, + custom_errmsg.format(name='vec', angle=45.0) + ) + + def test_check_segment_angle(self): + errmsg = 'The angle of vec is incorrect. Your angle: 45.0' + vectors = {'vec': self.vector(1, 1, 5, 5)} + self.assertPasses(grader.check_segment_angle, self.check(45, 0.1), vectors) + self.assertPasses(grader.check_segment_angle, self.check(-315, 0.1), vectors) + self.assertPasses(grader.check_segment_angle, self.check(405, 0.1), vectors) + self.assertPasses(grader.check_segment_angle, self.check(42, 5), vectors) + self.assertFails(grader.check_segment_angle, self.check(-405, 0.1), vectors, errmsg) + self.assertFails(grader.check_segment_angle, self.check(315, 0.1), vectors, errmsg) + self.assertFails(grader.check_segment_angle, self.check(-45, 9), vectors, errmsg) + custom_errmsg = 'Segment angle is incorrect.' + self.assertFails( + grader.check_segment_angle, self.check(-45, 9, errmsg=custom_errmsg), vectors, + custom_errmsg + ) + + def test_check_points_on_line(self): + errmsg = 'The line vec does not pass through the correct points.' + vectors = {'vec': self.vector(1, 1, 5, 5)} + self.assertPasses(grader.check_points_on_line, self.check([[1, 1], [5, 5]]), vectors) + self.assertPasses(grader.check_points_on_line, self.check([[1, 2], [5, 4]]), vectors) + self.assertPasses(grader.check_points_on_line, self.check([[3.7, 2.3], [2.3, 3.7]]), vectors) + self.assertPasses(grader.check_points_on_line, self.check([[-1, -.5], [98, 99]]), vectors) + self.assertFails(grader.check_points_on_line, self.check([[1, -1]]), vectors, errmsg) + self.assertFails(grader.check_points_on_line, self.check([[3.8, 2.2]]), vectors, errmsg) + self.assertFails(grader.check_points_on_line, self.check([[18, 13]]), vectors, errmsg) + vectors = {'vec': self.vector(1, 1, 1, 5)} # vertical line + self.assertPasses(grader.check_points_on_line, self.check([[1, 3], [1, 99], [1.9, 55]]), vectors) + self.assertFails(grader.check_points_on_line, self.check([[2.1, 3]]), vectors, errmsg) + vectors = {'vec': self.vector(1, 1, 5, 1)} # horizontal line + self.assertPasses(grader.check_points_on_line, self.check([[3, 1], [99, 1], [55, 1.9]]), vectors) + self.assertFails(grader.check_points_on_line, self.check([[3, 2.1]]), vectors, errmsg) diff --git a/vectordraw/__init__.py b/vectordraw/__init__.py new file mode 100644 index 0000000..77a4f14 --- /dev/null +++ b/vectordraw/__init__.py @@ -0,0 +1,6 @@ +""" +Top-level package for Vector Drawing XBlock. + +See vectordraw.vectordraw for more information. +""" +from .vectordraw import VectorDrawXBlock diff --git a/vectordraw/grader.py b/vectordraw/grader.py new file mode 100644 index 0000000..376b62f --- /dev/null +++ b/vectordraw/grader.py @@ -0,0 +1,386 @@ +""" +This module contains grading logic for Vector Drawing exercises. + +It uses the following data structures: + +- `vectors`: A dictionary of Vector objects. + + Keys are vector names and values represent individual vectors that were present + on the drawing board when the student submitted an answer by clicking the 'Check' button. + +- `points`: A dictionary of Point objects. + + Keys are point names and values represent individual points that were present + on the drawing board when the student submitted an answer by clicking the 'Check' button. + +- `check`: A dictionary representing a specific check. + + Contains the name of the check itself (e.g., 'presence', 'coords', 'angle'), + the name of the element on which to perform the check, as well as + the expected value of the property being checked. + Optionally contains information about tolerance to apply when performing the check, + and/or a custom error message to present to the user if the check fails. + +- `answer`: A dictionary representing a specific answer submitted by a student. + + Contains three entries: vectors, points, and checks. The first two (vectors, points) + provide information about vectors and points present on the drawing board + when the answer was submitted. The third one (checks) specifies the checks + to perform for individual vectors and points. + +""" + +# pylint: disable=invalid-name + +import inspect +import logging +import math + + +log = logging.getLogger(__name__) # pylint: disable=invalid-name + + +# Built-in check functions + +def _errmsg(default_message, check, vectors): + """ + Return error message for `check` targeting a vector from `vectors`. + + If `check` does not define a custom error message, fall back on `default_message`. + """ + template = check.get('errmsg', default_message) + vec = vectors[check['vector']] + return template.format( + name=vec.name, + tail_x=vec.tail.x, + tail_y=vec.tail.y, + tip_x=vec.tip.x, + tip_y=vec.tip.y, + length=vec.length, + angle=vec.angle + ) + + +def _errmsg_point(default_message, check, point): + """ + Return error message for `check` targeting `point`. + + If `check` does not define a custom error message, fall back on `default_message`. + """ + template = check.get('errmsg', default_message) + return template.format(name=check['point'], x=point.x, y=point.y) + + +def check_presence(check, vectors): + """ + Check if `vectors` contains vector targeted by `check`. + """ + if check['vector'] not in vectors: + errmsg = check.get('errmsg', 'You need to use the {name} vector.') + raise ValueError(errmsg.format(name=check['vector'])) + + +def _check_vector_endpoint(check, vectors, endpoint): + """ + Check if `endpoint` (tail or tip) of vector targeted by `check` is in correct position. + """ + vec = vectors[check['vector']] + tolerance = check.get('tolerance', 1.0) + expected = check['expected'] + verb = 'start' if endpoint == 'tail' else 'end' + endpoint = getattr(vec, endpoint) + dist = math.hypot(expected[0] - endpoint.x, expected[1] - endpoint.y) + if dist > tolerance: + raise ValueError(_errmsg( + 'Vector {name} does not {verb} at correct point.'.format(name='{name}', verb=verb), + check, + vectors + )) + + +def check_tail(check, vectors): + """ + Check if tail of vector targeted by `check` is in correct position. + """ + return _check_vector_endpoint(check, vectors, endpoint='tail') + + +def check_tip(check, vectors): + """ + Check if tip of vector targeted by `check` is in correct position. + """ + return _check_vector_endpoint(check, vectors, endpoint='tip') + + +def _check_coordinate(check, coord): + """ + Check `coord` against expected value. + """ + tolerance = check.get('tolerance', 1.0) + expected = check['expected'] + return abs(expected - coord) > tolerance + + +def check_tail_x(check, vectors): + """ + Check if x position of tail of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + if _check_coordinate(check, vec.tail.x): + raise ValueError(_errmsg('Vector {name} does not start at correct point.', check, vectors)) + + +def check_tail_y(check, vectors): + """ + Check if y position of tail of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + if _check_coordinate(check, vec.tail.y): + raise ValueError(_errmsg('Vector {name} does not start at correct point.', check, vectors)) + + +def check_tip_x(check, vectors): + """ + Check if x position of tip of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + if _check_coordinate(check, vec.tip.x): + raise ValueError(_errmsg('Vector {name} does not end at correct point.', check, vectors)) + + +def check_tip_y(check, vectors): + """ + Check if y position of tip of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + if _check_coordinate(check, vec.tip.y): + raise ValueError(_errmsg('Vector {name} does not end at correct point.', check, vectors)) + + +def _coord_delta(expected, actual): + """ + Return distance between `expected` and `actual` coordinates. + """ + if expected == '_': + return 0 + else: + return expected - actual + + +def _coords_within_tolerance(vec, expected, tolerance): + """ + Check if distance between coordinates of `vec` and `expected` coordinates is within `tolerance`. + """ + for expected_coords, vec_coords in ([expected[0], vec.tail], [expected[1], vec.tip]): + delta_x = _coord_delta(expected_coords[0], vec_coords.x) + delta_y = _coord_delta(expected_coords[1], vec_coords.y) + if math.hypot(delta_x, delta_y) > tolerance: + return False + return True + + +def check_coords(check, vectors): + """ + Check if coordinates of vector targeted by `check` are in correct position. + """ + vec = vectors[check['vector']] + expected = check['expected'] + tolerance = check.get('tolerance', 1.0) + if not _coords_within_tolerance(vec, expected, tolerance): + raise ValueError(_errmsg('Vector {name} coordinates are not correct.', check, vectors)) + + +def check_segment_coords(check, vectors): + """ + Check if coordinates of segment targeted by `check` are in correct position. + """ + vec = vectors[check['vector']] + expected = check['expected'] + tolerance = check.get('tolerance', 1.0) + if not (_coords_within_tolerance(vec, expected, tolerance) or + _coords_within_tolerance(vec.opposite(), expected, tolerance)): + raise ValueError(_errmsg('Segment {name} coordinates are not correct.', check, vectors)) + + +def check_length(check, vectors): + """ + Check if length of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + tolerance = check.get('tolerance', 1.0) + if abs(vec.length - check['expected']) > tolerance: + raise ValueError(_errmsg( + 'The length of {name} is incorrect. Your length: {length:.1f}', check, vectors + )) + + +def _angle_within_tolerance(vec, expected, tolerance): + """ + Check if difference between angle of `vec` and `expected` angle is within `tolerance`. + """ + # Calculate angle between vec and identity vector with expected angle + # using the formula: + # angle = acos((A . B) / len(A)*len(B)) + x = vec.tip.x - vec.tail.x + y = vec.tip.y - vec.tail.y + dot_product = x * math.cos(expected) + y * math.sin(expected) + angle = math.degrees(math.acos(dot_product / vec.length)) + return abs(angle) <= tolerance + + +def check_angle(check, vectors): + """ + Check if angle of vector targeted by `check` is correct. + """ + vec = vectors[check['vector']] + tolerance = check.get('tolerance', 2.0) + expected = math.radians(check['expected']) + if not _angle_within_tolerance(vec, expected, tolerance): + raise ValueError( + _errmsg('The angle of {name} is incorrect. Your angle: {angle:.1f}', check, vectors) + ) + + +def check_segment_angle(check, vectors): + """ + Check if angle of segment targeted by `check` is correct. + """ + # Segments are not directed, so we must check the angle between the segment and + # the vector that represents it, as well as its opposite vector. + vec = vectors[check['vector']] + tolerance = check.get('tolerance', 2.0) + expected = math.radians(check['expected']) + if not (_angle_within_tolerance(vec, expected, tolerance) or + _angle_within_tolerance(vec.opposite(), expected, tolerance)): + raise ValueError( + _errmsg('The angle of {name} is incorrect. Your angle: {angle:.1f}', check, vectors) + ) + + +def _dist_line_point(line, point): + """ + Return distance between `line` and `point`. + + The line is passed in as a Vector instance, the point as a Point instance. + """ + direction_x = line.tip.x - line.tail.x + direction_y = line.tip.y - line.tail.y + determinant = (point.x - line.tail.x) * direction_y - (point.y - line.tail.y) * direction_x + return abs(determinant) / math.hypot(direction_x, direction_y) + + +def check_points_on_line(check, vectors): + """ + Check if line targeted by `check` passes through correct points. + """ + line = vectors[check['vector']] + tolerance = check.get('tolerance', 1.0) + points = check['expected'] + for point in points: + point = Point(point[0], point[1]) + if _dist_line_point(line, point) > tolerance: + raise ValueError(_errmsg( + 'The line {name} does not pass through the correct points.', check, vectors + )) + + +def check_point_coords(check, points): + """ + Check if coordinates of point targeted by `check` are correct. + """ + point = points[check['point']] + tolerance = check.get('tolerance', 1.0) + expected = check['expected'] + dist = math.hypot(expected[0] - point.x, expected[1] - point.y) + if dist > tolerance: + return _errmsg_point('Point {name} is not at the correct location.', check, point) + + +class Point(object): + """ Represents a single point on the vector drawing board. """ + def __init__(self, x, y): + self.x = x + self.y = y + + +class Vector(object): + """ Represents a single vector on the vector drawing board. """ + def __init__(self, name, x1, y1, x2, y2): # pylint: disable=too-many-arguments + self.name = name + self.tail = Point(x1, y1) + self.tip = Point(x2, y2) + self.length = math.hypot(x2 - x1, y2 - y1) + angle = math.degrees(math.atan2(y2 - y1, x2 - x1)) + if angle < 0: + angle += 360 + self.angle = angle + + def opposite(self): + """ + Return new vector with tip and tail swapped. + """ + return Vector(self.name, self.tip.x, self.tip.y, self.tail.x, self.tail.y) + + +class Grader(object): + """ + Implements grading logic for student answers to Vector Drawing exercises. + """ + check_registry = { + 'presence': check_presence, + 'tail': check_tail, + 'tip': check_tip, + 'tail_x': check_tail_x, + 'tail_y': check_tail_y, + 'tip_x': check_tip_x, + 'tip_y': check_tip_y, + 'coords': check_coords, + 'length': check_length, + 'angle': check_angle, + 'segment_angle': check_segment_angle, + 'segment_coords': check_segment_coords, + 'points_on_line': check_points_on_line, + 'point_coords': check_point_coords, + } + + def __init__(self, success_message='Test passed', custom_checks=None): + self.success_message = success_message + if custom_checks: + self.check_registry.update(custom_checks) + + def grade(self, answer): + """ + Check correctness of `answer` by running checks defined for it one by one. + + Short-circuit as soon as a single check fails. + """ + check_data = dict( + vectors=self._get_vectors(answer), + points=self._get_points(answer), + ) + for check in answer['checks']: + check_data['check'] = check + check_fn = self.check_registry[check['check']] + args = [check_data[arg] for arg in inspect.getargspec(check_fn).args] + try: + check_fn(*args) + except ValueError as e: + return {'correct': False, 'msg': e.message} + return {'correct': True, 'msg': self.success_message} + + def _get_vectors(self, answer): # pylint: disable=no-self-use + """ + Turn vector info in `answer` into a dictionary of Vector objects. + """ + vectors = {} + for name, props in answer['vectors'].iteritems(): + tail = props['tail'] + tip = props['tip'] + vectors[name] = Vector(name, tail[0], tail[1], tip[0], tip[1]) + return vectors + + def _get_points(self, answer): # pylint: disable=no-self-use + """ + Turn point info in `answer` into a dictionary of Point objects. + """ + return {name: Point(*coords) for name, coords in answer['points'].iteritems()} diff --git a/vectordraw/public/css/vectordraw.css b/vectordraw/public/css/vectordraw.css new file mode 100644 index 0000000..150a211 --- /dev/null +++ b/vectordraw/public/css/vectordraw.css @@ -0,0 +1,172 @@ +/* CSS for VectorDrawXBlock */ + +.vectordraw_block, +.vectordraw_block #vectordraw { + display: inline-block; +} + +.vectordraw_block .vectordraw-description, +.vectordraw_block #vectordraw, +.vectordraw_block .vectordraw-status { + margin-bottom: 1.5em; +} + +.vectordraw_block .jxgboard { + float: left; + border: 2px solid #1f628d; +} + +.vectordraw_block .jxgboard .JXGtext { + pointer-events: none; /* prevents cursor from turning into caret when over a label */ +} + +/* Menu */ + +.vectordraw_block .menu { + width: 100%; +} + +.vectordraw_block .menu, +.vectordraw_block .menu .controls { + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background-color: #e0e0e0; + font-size: 0; +} + +.vectordraw_block .menu { + border-top: 2px solid #1f628d; + border-left: 2px solid #1f628d; + border-right: 2px solid #1f628d; +} + +.vectordraw_block .menu .controls { + border-bottom: 2px solid #1f628d; + padding: 3px; +} + +.vectordraw_block .menu .controls select { + width: 160px; + margin-right: 3px; + font-size: 18px; +} + +.vectordraw_block .menu button { + border: 1px solid #1f628d; + border-radius: 5px; + margin: 4px 0; + padding: 5px 10px 5px 10px; + box-shadow: 0 1px 3px #666; + background-color: #c2e0f4; + color: #1f628d; + font-size: 14px; + text-decoration: none; +} + +.vectordraw_block .menu button:focus, +.vectordraw_block .menu button:hover { + background: #c2e0f4; + background-image: -webkit-linear-gradient(top, #c2e0f4, #add5f0); + background-image: -moz-linear-gradient(top, #c2e0f4, #add5f0); + background-image: -ms-linear-gradient(top, #c2e0f4, #add5f0); + background-image: -o-linear-gradient(top, #c2e0f4, #add5f0); + background-image: linear-gradient(to bottom, #c2e0f4, #add5f0); + text-decoration: none; +} + +.vectordraw_block .menu .controls button.reset, +.vectordraw_block .menu .controls button.undo, +.vectordraw_block .menu .controls button.redo { + float: right; +} + +.vectordraw_block .menu .vector-properties { + padding: 10px; + font-size: 16px; + line-height: 1.25; + background-color: #f7f7f7; +} + +.vectordraw_block h3 { + font-size: 16px; + margin: 0 0 5px; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list { + display: table; + width: 100% +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row { + display: table-row; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop { + display: table-cell; + width: 50% +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop-name, +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop-tail, +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop-angle { + padding-right: 5px; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row span, +.vectordraw_block .menu .vector-properties .vector-prop-list .row select, +.vectordraw_block .menu .vector-properties .vector-prop-list .row input { + width: 50%; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row select, +.vectordraw_block .menu .vector-properties .vector-prop-list .row input { + float: right; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop-update .update-error { + display: none; + color: #ff0000; +} + +.vectordraw_block .menu .vector-properties button:disabled { + pointer-events: none; + border: 1px solid #707070; + background-color: #ececec; + color: #868686; +} + +.vectordraw_block .action button { + height: 40px; + margin-right: 10px; + font-weight: 600; + text-transform: uppercase; +} + +/* Make sure screen-reader content is hidden in the workbench: */ +.vectordraw_block .sr { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + background: #ffffff; + color: #000000; +} + +.vectordraw_block .vectordraw-status { + display: inline-block; + width: 100%; +} + +.vectordraw_block .checkmark-correct { + font-size: 22pt; + color: #629b2b; +} + +.vectordraw_block .checkmark-incorrect { + font-size: 22pt; + color: #ff0000; +} diff --git a/vectordraw/public/css/vectordraw_edit.css b/vectordraw/public/css/vectordraw_edit.css new file mode 100644 index 0000000..de598f8 --- /dev/null +++ b/vectordraw/public/css/vectordraw_edit.css @@ -0,0 +1,122 @@ +.vectordraw_edit_block, +.vectordraw_edit_block .jxgboard { + display: block; +} + +.vectordraw_edit_block { + border-top: 1px solid #e5e5e5; + margin-left: 20px; + margin-right: 20px; + padding-top: 20px; +} + +.vectordraw_edit_block h2 { + margin-bottom: 1em; +} + +.vectordraw_edit_block #wysiwyg-description { + display: none; +} + +.vectordraw_edit_block p { + margin-bottom: 1em; + font-size: 0.9em; +} + +.vectordraw_edit_block .jxgboard { + float: left; + margin-bottom: 1em; +} + +/* Menu */ + +.vectordraw_edit_block .menu .controls .result-mode { + float: right; +} + +.vectordraw_edit_block .menu .controls button:disabled { + pointer-events: none; + border: 1px solid #707070; + background-color: #ececec; + color: #868686; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-prop-name, +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-prop-tail, +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-prop-angle { + padding-right: 0px; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-select, +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-prop-label, +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-prop-length { + padding-right: 5px; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row select, +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row input { + height: 2em; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row select { + padding: 0px; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-remove { + vertical-align: bottom; +} + +.vectordraw_edit_block .menu .vector-properties .vector-prop-list .row .vector-remove button { + float: right; +} + +.vectordraw_block .menu .vector-properties .vector-prop-list .row .vector-prop-update .update-pending { + display: none; + color: #ffa500; +} + +.vectordraw_edit_block h3 { + margin-top: 5px; + margin-bottom: 5px; +} + +.vectordraw_edit_block .checks { + display: none; + width: 220px; + float: right; + border-top: 2px solid #1f628d; + border-right: 2px solid #1f628d; + border-bottom: 2px solid #1f628d; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + padding-left: 10px; + background-color: #f7f7f7; + overflow: auto; +} + +.vectordraw_edit_block .checks .check .row { + height: 2em; +} + +.vectordraw_edit_block .checks .check span, +.vectordraw_edit_block .checks .check input[type="number"] { + height: 1.5em; + min-width: 0px; + margin-right: 12px; +} + +.vectordraw_edit_block .checks .check input[type="number"] { + padding: 0 0 0 5px; +} + +.vectordraw_edit_block .checks .check input[type="checkbox"] { + height: 1.2em; + width: 20%; + min-width: 0px; + margin-top: 2px; +} + +.vectordraw_edit_block .checks .check input { + float: right; + vertical-align: middle; +} diff --git a/vectordraw/public/js/studio_edit.js b/vectordraw/public/js/studio_edit.js new file mode 100644 index 0000000..adfb3b7 --- /dev/null +++ b/vectordraw/public/js/studio_edit.js @@ -0,0 +1,138 @@ +/* Javascript for StudioEditableXBlockMixin. */ +function StudioEditableXBlockMixin(runtime, element) { + "use strict"; + + var fields = []; + var tinyMceAvailable = (typeof $.fn.tinymce !== 'undefined'); // Studio includes a copy of tinyMCE and its jQuery plugin + var errorMessage = gettext("This may be happening because of an error with our server or your internet connection. Make sure you are online, and try refreshing the page."); + + $(element).find('.field-data-control').each(function() { + var $field = $(this); + var $wrapper = $field.closest('li'); + var $resetButton = $wrapper.find('button.setting-clear'); + var type = $wrapper.data('cast'); + fields.push({ + name: $wrapper.data('field-name'), + isSet: function() { return $wrapper.hasClass('is-set'); }, + hasEditor: function() { return tinyMceAvailable && $field.tinymce(); }, + val: function() { + var val = $field.val(); + // Cast values to the appropriate type so that we send nice clean JSON over the wire: + if (type === 'boolean') + return (val === 'true' || val === '1'); + if (type === "integer") + return parseInt(val, 10); + if (type === "float") + return parseFloat(val); + return val; + }, + removeEditor: function() { + $field.tinymce().remove(); + } + }); + var fieldChanged = function() { + // Field value has been modified: + $wrapper.addClass('is-set'); + $resetButton.removeClass('inactive').addClass('active'); + }; + $field.bind("change input paste", fieldChanged); + $resetButton.click(function() { + $field.val($wrapper.attr('data-default')); // Use attr instead of data to force treating the default value as a string + $wrapper.removeClass('is-set'); + $resetButton.removeClass('active').addClass('inactive'); + }); + if (type === 'html' && tinyMceAvailable) { + tinyMCE.baseURL = baseUrl + "/js/vendor/tinymce/js/tinymce"; + $field.tinymce({ + theme: 'modern', + skin: 'studio-tmce4', + height: '200px', + formats: { code: { inline: 'code' } }, + codemirror: { path: "" + baseUrl + "/js/vendor" }, + convert_urls: false, + plugins: "link codemirror", + menubar: false, + statusbar: false, + toolbar_items_size: 'small', + toolbar: "formatselect | styleselect | bold italic underline forecolor wrapAsCode | bullist numlist outdent indent blockquote | link unlink | code", + resize: "both", + setup : function(ed) { + ed.on('change', fieldChanged); + } + }); + } + + }); + + var studio_submit = function(data) { + var handlerUrl = runtime.handlerUrl(element, 'submit_studio_edits'); + runtime.notify('save', {state: 'start', message: gettext("Saving")}); + $.ajax({ + type: "POST", + url: handlerUrl, + data: JSON.stringify(data), + dataType: "json", + notifyOnError: false + }).done(function(response) { + runtime.notify('save', {state: 'end'}); + }).fail(function(jqXHR) { + if (jqXHR.responseText) { // Is there a more specific error message we can show? + try { + errorMessage = JSON.parse(jqXHR.responseText).error; + if (_.isObject(errorMessage) && errorMessage.messages) { + // e.g. {"error": {"messages": [{"text": "Unknown user 'bob'!", "type": "error"}, ...]}} etc. + var errorMessages = _.pluck(errorMessage.messages, "text"); + errorMessage = errorMessages.join(", "); + } + } catch (error) { errorMessage = jqXHR.responseText.substr(0, 300); } + } + runtime.notify('error', {title: gettext("Unable to update settings"), message: errorMessage}); + }); + }; + + return { + + getContents: function(fieldName) { + return _.findWhere(fields, {name: fieldName}).val(); + }, + + save: function(data) { + var values = {}; + var notSet = []; // List of field names that should be set to default values + _.each(fields, function(field) { + if (field.isSet()) { + values[field.name] = field.val(); + } else { + notSet.push(field.name); + } + // Remove TinyMCE instances to make sure jQuery does not try to access stale instances + // when loading editor for another block: + if (field.hasEditor()) { + field.removeEditor(); + } + }); + // If WYSIWYG editor was used, + // prefer its data over values of "Vectors" and "Expected result" fields: + if (!_.isEmpty(data)) { + values.vectors = JSON.stringify(data.vectors, undefined, 4); + values.expected_result_positions = data.expected_result_positions; + values.expected_result = JSON.stringify(data.expected_result, undefined, 4); + } + + studio_submit({values: values, defaults: notSet}); + }, + + cancel: function() { + // Remove TinyMCE instances to make sure jQuery does not try to access stale instances + // when loading editor for another block: + _.each(fields, function(field) { + if (field.hasEditor()) { + field.removeEditor(); + } + }); + runtime.notify('cancel', {}); + } + + }; + +} diff --git a/vectordraw/public/js/vectordraw.js b/vectordraw/public/js/vectordraw.js new file mode 100644 index 0000000..328cbf4 --- /dev/null +++ b/vectordraw/public/js/vectordraw.js @@ -0,0 +1,634 @@ +/* Javascript for VectorDrawXBlock. */ +function VectorDrawXBlock(runtime, element, init_args) { + 'use strict'; + + // Logic for rendering and interacting with vector drawing exercise + + var VectorDraw = function(element_id, settings) { + this.board = null; + this.dragged_vector = null; + this.drawMode = false; + this.history_stack = {undo: [], redo: []}; + this.settings = settings; + this.element = $('#' + element_id, element); + + this.element.on('click', '.reset', this.reset.bind(this)); + this.element.on('click', '.add-vector', this.addElementFromList.bind(this)); + this.element.on('click', '.undo', this.undo.bind(this)); + this.element.on('click', '.redo', this.redo.bind(this)); + this.element.on('change', '.menu .element-list-edit', this.onEditStart.bind(this)); + this.element.on('click', '.menu .vector-prop-update', this.onEditSubmit.bind(this)); + // Prevents default image drag and drop actions in some browsers. + this.element.on('mousedown', '.jxgboard image', function(evt) { evt.preventDefault(); }); + + this.render(); + }; + + VectorDraw.prototype.render = function() { + $('.vector-prop-slope', this.element).hide(); + // Assign the jxgboard element a random unique ID, + // because JXG.JSXGraph.initBoard needs it. + this.element.find('.jxgboard').prop('id', _.uniqueId('jxgboard')); + this.createBoard(); + }; + + VectorDraw.prototype.createBoard = function() { + var id = this.element.find('.jxgboard').prop('id'), + self = this; + + this.board = JXG.JSXGraph.initBoard(id, { + keepaspectratio: true, + boundingbox: this.settings.bounding_box, + axis: this.settings.axis, + showCopyright: false, + showNavigation: this.settings.show_navigation + }); + + function getImageRatio(bg, callback) { + $('').attr('src', bg.src).load(function() { + //technically it's inverse of ratio, but we need it to calculate height + var ratio = this.height / this.width; + callback(bg, ratio); + }); + } + + function drawBackground(bg, ratio) { + var height = (bg.height) ? bg.height : bg.width * ratio; + var coords = (bg.coords) ? bg.coords : [-bg.width/2, -height/2]; + var image = self.board.create('image', [bg.src, coords, [bg.width, height]], {fixed: true}); + $(image.rendNode).attr('alt', bg.description); + } + + if (this.settings.background) { + if (this.settings.background.height) { + drawBackground(this.settings.background); + } + else { + getImageRatio(this.settings.background, drawBackground); + } + } + + function renderAndSetMenuOptions(element, idx, type, board) { + if (element.render) { + if (type === 'point') { + board.renderPoint(idx); + } else { + board.renderVector(idx); + } + } else { + // Enable corresponding option in menu for adding vectors ... + var addOption = board.getAddMenuOption(type, idx); + addOption.prop('disabled', false); + // ... and select it if no option is currently selected + if ($('.menu .element-list-add option').filter(':selected').length === 0) { + addOption.prop('selected', true); + } + // Disable corresponding option in menu for editing vectors + var editOption = board.getEditMenuOption(type, idx); + editOption.prop('disabled', true); + } + } + + // a11y + + // Generate and set unique ID for "Vector Properties"; + // this is necessary to ensure that "aria-describedby" associations + // between vectors and the "Vector Properties" box don't break + // when multiple boards are present: + var vectorProperties = $(".vector-properties", element); + vectorProperties.attr("id", id + "-vector-properties"); + + // Draw vectors and points + _.each(this.settings.points, function(point, idx) { + renderAndSetMenuOptions(point, idx, 'point', this); + }, this); + + _.each(this.settings.vectors, function(vec, idx) { + renderAndSetMenuOptions(vec, idx, 'vector', this); + }, this); + + // Set up event handlers + this.board.on('down', this.onBoardDown.bind(this)); + this.board.on('move', this.onBoardMove.bind(this)); + this.board.on('up', this.onBoardUp.bind(this)); + }; + + VectorDraw.prototype.renderPoint = function(idx, coords) { + var point = this.settings.points[idx]; + var coords = coords || point.coords; + var board_object = this.board.elementsByName[point.name]; + if (board_object) { + // If the point is already rendered, only update its coordinates. + board_object.setPosition(JXG.COORDS_BY_USER, coords); + return; + } + this.board.create('point', coords, point.style); + if (!point.fixed) { + // Disable the