test_SphinxBookExtension.py - Unit tests

Invocation:

Imports

These are listed in the order prescribed by PEP 8.

Standard library

import sys
from unittest.mock import patch, mock_open, call
from pathlib import Path

Third-party imports

import pytest
from docutils import nodes
from docutils.parsers.rst import Directive, DirectiveError

Local imports

from SphinxBookExtension import (
    _doctree_resolved, _update_iq_data, _purge_js, _get_question_args, _ShortAnswerDirective,
    _TestButtonDirective, _PageIdDirective, _GradebookDirective, _GradebookNode,
    _remove_code_solutions, _textarea_replacement,
    TEXTAREA_REPLACEMENT_STRING, _source_read, BOOK_BINDER_LIB_PATH,
)
sys.path.insert(0, BOOK_BINDER_LIB_PATH)
from book_binder_lib import GraderType, _add_line_comment_delimiter

Mock classes

class MockApp:
    def __init__(self, docname):
        self.builder = MockBuilder(docname)
        self.env = self.builder.env
        self.outdir = '_build/html'

class MockBuilder:
    def __init__(self, docname):
        self.env = MockEnv(docname)

class MockEnv:
    def __init__(self, docname):
        self.docname = docname

    def doc2path(self, docname, *args):
        return docname

class MockDirective(Directive):
    def __init__(self, env):
        super().__init__(**mock_directive_kwargs(env))

def mock_directive_kwargs(

The mock build environment for this directive.

  env,

Arguments for the directive – they must all be strings.

  *args,

Overrides for any of the returned keyword arguments.

  **kwargs):

    return_kwargs =  {
        'name': 'test-directive',
        'arguments': list(args),
        'options': [],
        'content': ['stuff'],
        'lineno': 1,
        'content_offset': 1,
        'block_text': '',
        'state': MockState(env),
        'state_machine': None,
    }
    return_kwargs.update(kwargs)
    return return_kwargs

class MockState:
    field_name0_str = 'fieldname'
    field_body0_str = 'short'
    field_name1_str = 'field_name'
    field_body1_str = 'field_body\nthat is long'

    def __init__(self, env):
        self.document = MockDocument(env)

    def nested_parse(self, content, content_offset, node):

Create two answers.

        node += nodes.field()
        node[-1].line = 4
        node[-1] += nodes.field_name(self.field_name0_str)
        node[-1] += nodes.field_body(self.field_body0_str)
        node += nodes.field()
        node[-1].line = 6
        node[-1] += nodes.field_name(self.field_name1_str)
        node[-1] += nodes.field_body(self.field_body1_str)

class MockDocument:
    def __init__(self, env):
        self.settings = MockSettings(env)

class MockSettings:
    def __init__(self, env):
        self.env = env

def mock_doctree():
    return nodes.Element()

Supporting utilities

Set up the interactive_questions data structure.

def iq_setup():
    app = MockApp('test.c')
    env = app.builder.env
    docname = env.docname
    _purge_js(app, env, docname)
    return app, env
 

Docutils exceptions store the error message in a non-standard place, so excinfo.match (see the end of http://doc.pytest.org/en/latest/assert.html#assertions-about-expected-exceptions) doesn’t work. Provide a work-around.

def doc_msg(excinfo):

The excinfo structure stores the actual exception in .value. In docutils.parsers.rst.Directive, the exception message is stored in .msg.

    return excinfo.value.msg

Tests

Check that a repeated question label on a page raises an exception.

def test_1():
    app, env = iq_setup()
    _ShortAnswerDirective(**mock_directive_kwargs(env, 'q1')).run()
    with pytest.raises(DirectiveError) as excinfo:
        _ShortAnswerDirective(**mock_directive_kwargs(env, 'q1')).run()
    assert 'not unique on this page' in doc_msg(excinfo)
 

Test the collection of arguments for all question directives.

def test_2():
    app, env = iq_setup()
    d = MockDirective(env)
 

If the points possible aren’t specificed, they should default to 1.

    d.arguments = ['q1']
    id_, pointsPossible = _get_question_args(d)
    assert id_ == 'q1'
    assert pointsPossible == 1
 

Specify the pointsPossible.

    d.arguments = ['q1', '5']
    id_, pointsPossible = _get_question_args(d)
    assert id_ == 'q1'
    assert pointsPossible == 5
 

Verify a short answer question.

def test_3():
    app, env = iq_setup()
    nodes = _ShortAnswerDirective(**mock_directive_kwargs(env, 'q1', '2', 'regexp')).run()

Nodes expected structure:

[ container
    [ raw
        [ text
            .astext() contains actual raw text.
        ]
    ]
]

Check the generated nodes.

    t = nodes[-1][0][0].astext()
    assert """
    <input type="text" id="q1" onkeyup="this.onTextSubmitted(event);" />
    <input type="button" value="Check"
        onclick="this.previousElementSibling.onTextSubmitted(event);" />
    <span></span>
    """ == t
 

Check the generated data.

    iq = env.interactive_questions
    assert iq.javaScript[env.docname] == "    TextQuestion('q1'),"
    qs = iq.pages[env.docname].questions['q1']
    assert qs.pointsPossible == 2
    assert qs.grader == GraderType.regexp
 

Test answer collection.

def test_3_1():
    app, env = iq_setup()
    _ShortAnswerDirective(**mock_directive_kwargs(env, 'q1', '2', 'regexp')).run()

Check the generated data.

    fb = env.interactive_questions.feedback_raw[env.docname]
    assert fb[0].line == 4
    assert fb[0].field_name_raw == MockState.field_name0_str
    assert fb[0].field_body_raw == MockState.field_body0_str
    assert fb[1].line == 6
    assert fb[1].field_name_raw == MockState.field_name1_str
    assert fb[1].field_body_raw == MockState.field_body1_str
 
 

TODO: Check the checkbox and radiobutton questions. I’ll need to build a nice node structure as input, so I’ve skipped this for now.

 

Verify a test button.

def test_4():
    app, env = iq_setup()
 

Make sure content is disallowed.

    with pytest.raises(DirectiveError) as excinfo:
        _TestButtonDirective(**mock_directive_kwargs(env, 'q1', '2')).run()
    assert 'Content block not allowed' in doc_msg(excinfo)
 

Create a valid test button.

    nodes = _TestButtonDirective(**mock_directive_kwargs(env, 'q1', '2', content=[])).run()

Nodes expected structure:

[ raw
    [ text
        .astext() contains actual raw text.
    ]
]

Check the generated data.

    assert """<input type="button" value="Test" id="q1" onclick="onTestButtonClicked(this);" />
                   <br />
                   <textarea rows=10 cols=80 readonly></textarea>
                   <br />""" in nodes[0][0].astext()
 

Check the generated data.

    iq = env.interactive_questions
    assert iq.javaScript[env.docname] == "    TestButtonQuestion('q1'),"
    qs = iq.pages[env.docname].questions['q1']
    assert qs.pointsPossible == 2
 

Verify the page ID directive.

def test_5():
    app, env = iq_setup()
 

Make sure content is disallowed.

    with pytest.raises(DirectiveError) as excinfo:
        _PageIdDirective(**mock_directive_kwargs(env, '1')).run()
    assert 'Content block not allowed' in doc_msg(excinfo)
 

Verify ID conversion.

    with pytest.raises(DirectiveError) as excinfo:
        _PageIdDirective(**mock_directive_kwargs(env, 'one', content=[])).run()
    assert 'Unable to convert the given id' in doc_msg(excinfo)
    with pytest.raises(DirectiveError) as excinfo:
        _PageIdDirective(**mock_directive_kwargs(env, '-1', content=[])).run()
    assert 'Unable to convert the given id' in doc_msg(excinfo)
 

Create a valid page ID.

    pd = _PageIdDirective(**mock_directive_kwargs(env, '2', content=[]))
    nodes = pd.run()
    assert not nodes
    iq = env.interactive_questions
    assert iq.pageIds[env.docname] == 2
    p = iq.pages[env.docname]
    assert p.id_ == 2
    assert p.line == pd.lineno
 

Verify duplicate exceptions.

    with pytest.raises(DirectiveError) as excinfo:
        _PageIdDirective(**mock_directive_kwargs(env, '3', content=[])).run()
    assert 'Duplicate page ID' in doc_msg(excinfo)
 

Test the _GradebookDirective.

def test_6():
    app, env = iq_setup()
 

Make sure content is disallowed.

    with pytest.raises(DirectiveError) as excinfo:
        _GradebookDirective(**mock_directive_kwargs(env, '1')).run()
    assert 'Content block not allowed' in doc_msg(excinfo)

    nodes = _GradebookDirective(**mock_directive_kwargs(env, '2', content=[])).run()
    assert len(nodes) == 1
    assert isinstance(nodes[0], _GradebookNode)
 

Test the doctree-resolved hook.

def test_7():
    app, env = iq_setup()
    docname = env.docname
 

If there are no questions, no nodes should be added.

    doctree = mock_doctree()
    with patch('SphinxBookExtension.open', mock_open()):
        _doctree_resolved(app, doctree, docname)
    assert len(doctree) == 0
 

Verify a node is added when a question is present.

    doctree = mock_doctree()
    _ShortAnswerDirective(**mock_directive_kwargs(env, 'q1')).run()
    with patch('SphinxBookExtension.open', mock_open(read_data="""// line 1
        // line 2
        // line 3
        // :fieldname: short
        // line 5
        // :field_name:
        // line 7
        // line 8
        //   field_body
        //   that is long
        // line 11
        // SOLUTION_""" """BEGIN
        // line 13
        // SOLUTION_""" """END
        // line 15""")) as m:
        with patch('SphinxBookExtension.Path.open', mock_open()) as n:
            _doctree_resolved(app, doctree, docname)
    assert len(doctree) == 1

Expected structure:

[ Element
    [ raw
        .astext() contains actual raw text.
    ]
]
    assert 'var questionList' in doctree[0][0].astext()

Check that the input file was opened for reading.

    m.assert_has_calls([call(env.docname, encoding='utf-8')])

Check that the output file was opened for writing. TODO: Check the path used.

    n.assert_has_calls([call('w', encoding='utf-8')])

Check that the correct data was written.

    n().write.assert_called_once_with("""// line 1
        // line 2
        // line 3
        // line 5
        // line 11\n""" # Need to presere line with spaces on them, so quote differently.
'// ``*******************************************``\n'
'// Add your code after this line.\n'
'// \n'
'// Add your code before this line.\n'
'// ``*******************************************``\n'
'        // line 15')
 

Test the code solution removal.

def test_8():

Note: to avoid the tags being recognized when this file is parsed by Sphinx, they’re broken apart in the strings below.

    assert _remove_code_solutions('foo.s', """# line 1
        # line 2
        # line 3
        # SOLUTION_""" """BEGIN
        Answer here.
        And here.
        Even more.
        # SOLUTION_""" """END
        # line 9
        # line 10
        # line 11
        #  SOLUTION_""" """BEGIN
        More answer stuff.
        # SOLUTION_""" """END
        # line 15""", lambda start_line, end_line, file_name: '        # Snip ' + str(end_line) + '\n') == """# line 1
        # line 2
        # line 3
        # Snip 8
        # line 9
        # line 10
        # line 11
        # Snip 14
        # line 15"""
 

Test the addition of comments to a string.

def test_9():
    assert _add_line_comment_delimiter('1\n2', 'foo.py') == '# 1\n# 2'
 

Test _textarea_replacement.

def test_10():

Check a mimimum-length replacement.

    assert _textarea_replacement(3, 4, 'foo.py') == TEXTAREA_REPLACEMENT_STRING.format(4)
 

Check a replcement with added lines.

    assert _textarea_replacement(3, 22, 'foo.py').count('\n') == 20
 

Test the source-read event callback.

def test_11():
    app, env = iq_setup()
    docname = env.docname

    src = (
["""Line 1
Line 2
SOLUTION_""" """BEGIN
Line 4
Line 5
Line 6
Line 7
Line 8
Line 9
Line 10
SOLUTION_""" """END
Line 12"""])
    _source_read(app, docname, src)
    assert (src ==
["""Line 1
Line 2

.. raw::
 html

 <textarea rows=4 cols=80 class="code_snippet"></textarea><br />

..


Line 12"""])