Skip to content

Commit f60fd4d

Browse files
Add HTML tests to avoid regressions (#33)
Essentially, copy the HTML test approach from the Sphinx project.
1 parent 3a5d005 commit f60fd4d

File tree

8 files changed

+664
-370
lines changed

8 files changed

+664
-370
lines changed

poetry.lock

Lines changed: 478 additions & 370 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ mypy = "^0.910"
3232
types-docutils = "^0.17.0"
3333
pep8-naming = "^0.13"
3434
coverage = "^6.5"
35+
lxml = "^4.9.2"
36+
lxml-stubs = "^0.4.0"
3537

3638
[tool.poetry.group.dev.dependencies.isort]
3739
version = "^5.10"

test/conftest.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Test HTML output the same way that Sphinx does in test_build_html.py."""
2+
import re
3+
from itertools import chain, cycle
4+
from pathlib import Path
5+
from typing import Dict
6+
7+
import pytest
8+
from docutils import nodes
9+
from lxml import etree as lxmltree
10+
from sphinx.testing.path import path as sphinx_path
11+
from sphinx.testing.util import SphinxTestApp
12+
13+
pytest_plugins = "sphinx.testing.fixtures"
14+
15+
etree_cache: Dict[str, str] = {}
16+
17+
18+
@pytest.fixture(scope='session')
19+
def rootdir():
20+
return sphinx_path(__file__).parent.abspath() / 'roots'
21+
22+
23+
class SphinxBuilder:
24+
def __init__(self, app: SphinxTestApp, src_path: Path):
25+
self.app = app
26+
self._src_path = src_path
27+
28+
@property
29+
def src_path(self) -> Path:
30+
return self._src_path
31+
32+
@property
33+
def out_path(self) -> Path:
34+
return Path(self.app.outdir)
35+
36+
def build(self, assert_pass=True):
37+
self.app.build()
38+
if assert_pass:
39+
assert self.warnings == "", self.status
40+
return self
41+
42+
@property
43+
def status(self):
44+
return self.app._status.getvalue()
45+
46+
@property
47+
def warnings(self):
48+
return self.app._warning.getvalue()
49+
50+
def get_doctree(self, docname: str, post_transforms: bool = False) -> nodes.document:
51+
assert self.app.env is not None
52+
doctree = self.app.env.get_doctree(docname)
53+
if post_transforms:
54+
self.app.env.apply_post_transforms(doctree, docname)
55+
return doctree
56+
57+
58+
@pytest.fixture(scope='module')
59+
def cached_etree_parse():
60+
def parse(fname):
61+
if fname in etree_cache:
62+
return etree_cache[fname]
63+
with (fname).open('r') as fp:
64+
data = fp.read().replace('\n', '')
65+
etree = lxmltree.HTML(data)
66+
etree_cache.clear()
67+
etree_cache[fname] = etree
68+
return etree
69+
70+
yield parse
71+
etree_cache.clear()
72+
73+
74+
def flat_dict(d):
75+
return chain.from_iterable([zip(cycle([fname]), values) for fname, values in d.items()])
76+
77+
78+
def check_xpath(etree, fname, path, check, be_found=True):
79+
nodes = list(etree.xpath(path))
80+
if check is None:
81+
assert nodes == [], f'found any nodes matching xpath {path!r} in file {fname}'
82+
return
83+
else:
84+
assert nodes != [], f'did not find any node matching xpath {path!r} in file {fname}'
85+
if callable(check):
86+
check(nodes)
87+
elif not check:
88+
# only check for node presence
89+
pass
90+
else:
91+
92+
def get_text(node):
93+
if node.text is not None:
94+
# the node has only one text
95+
return node.text
96+
else:
97+
# the node has tags and text; gather texts just under the node
98+
return ''.join(n.tail or '' for n in node)
99+
100+
rex = re.compile(check)
101+
if be_found:
102+
if any(rex.search(get_text(node)) for node in nodes):
103+
return
104+
else:
105+
if all(not rex.search(get_text(node)) for node in nodes):
106+
return
107+
108+
raise AssertionError(f'{check!r} not found in any node matching path {path} in {fname}: {[node.text for node in nodes]!r}')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
extensions = ["sphinxarg.ext"]
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Sample
2+
######
3+
4+
.. argparse::
5+
:filename: test/sample-directive-opts.py
6+
:prog: sample-directive-opts
7+
:func: get_parser
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Command A
2+
=========
3+
4+
.. argparse::
5+
:filename: test/sample-directive-opts.py
6+
:prog: sample-directive-opts
7+
:func: get_parser
8+
:path: A

test/sample-directive-opts.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import argparse
2+
3+
4+
def get_parser():
5+
parser = argparse.ArgumentParser(prog='sample-directive-opts', description='Support SphinxArgParse HTML testing')
6+
subparsers = parser.add_subparsers()
7+
parser_a = subparsers.add_parser('A', help='A subparser')
8+
parser_a.add_argument('baz', type=int, help='An integer')
9+
parser_b = subparsers.add_parser('B', help='B subparser')
10+
parser_b.add_argument('--barg', choices='XYZ', help='A list of choices')
11+
12+
parser.add_argument('--foo', help='foo help')
13+
parser.add_argument('foo2', metavar='foo2 metavar', help='foo2 help')
14+
grp1 = parser.add_argument_group('bar options')
15+
grp1.add_argument('--bar', help='bar help')
16+
grp1.add_argument('quux', help='quux help')
17+
grp2 = parser.add_argument_group('bla options')
18+
grp2.add_argument('--blah', help='blah help')
19+
grp2.add_argument('sniggly', help='sniggly help')
20+
21+
return parser

test/test_default_html.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Test the HTML builder and check output against XPath."""
2+
3+
import pytest
4+
5+
from .conftest import check_xpath, flat_dict
6+
7+
8+
@pytest.mark.parametrize(
9+
"fname,expect",
10+
flat_dict(
11+
{
12+
'index.html': [
13+
(".//h1", 'Sample'),
14+
(".//h1", 'blah-blah', False),
15+
(".//div[@class='highlight']//span", 'usage'),
16+
(".//h2", 'Positional Arguments'),
17+
(".//section[@id='positional-arguments']", ''),
18+
(".//section[@id='positional-arguments']/dl/dt[1]/kbd", 'foo2 metavar'),
19+
(".//section[@id='named-arguments']", ''),
20+
(".//section[@id='named-arguments']/dl/dt[1]/kbd", '--foo'),
21+
(".//section[@id='bar-options']", ''),
22+
(".//section[@id='bar-options']/dl/dt[1]/kbd", '--bar'),
23+
],
24+
'subcommand-a.html': [
25+
(".//h1", 'Sample', False),
26+
(".//h1", 'Command A'),
27+
(".//div[@class='highlight']//span", 'usage'),
28+
(".//h2", 'Positional Arguments'),
29+
(".//section[@id='positional-arguments']", ''),
30+
(".//section[@id='positional-arguments']/dl/dt[1]/kbd", 'baz'),
31+
],
32+
}
33+
),
34+
)
35+
@pytest.mark.sphinx('html', testroot='default-html')
36+
def test_default_html(app, cached_etree_parse, fname, expect):
37+
app.build()
38+
print(app.outdir / fname)
39+
check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect)

0 commit comments

Comments
 (0)