215 lines
7.7 KiB
Python
215 lines
7.7 KiB
Python
"""Importing an HTML article as a guide.
|
|
|
|
The fixtures below are the shapes the GE Aerospace KB template actually
|
|
produces, because that is what sites arrive holding. Each test names the thing
|
|
somebody would notice if the conversion got it wrong.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from plugins.guides.services.htmlimport import parse
|
|
from plugins.guides.services.blocks import validate
|
|
|
|
|
|
ARTICLE = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>Configure The Thing - GE Aerospace Knowledge Base</title>
|
|
<style>.step { color: #003B5C; }</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<div class="header-logo"><svg width="138"><path d="M0 0"/></svg></div>
|
|
<h1>Configure The Thing</h1>
|
|
<p class="header-subtitle">Should not become a paragraph</p>
|
|
</div>
|
|
<div class="nav"><ul><li><a href="#overview">1. Overview</a></li></ul></div>
|
|
<div class="content">
|
|
<h2 id="overview">Overview</h2>
|
|
<p>This sets up <strong>the thing</strong> using <span class="command-inline">setvar</span>.</p>
|
|
<ul class="checkbox-list">
|
|
<li>A console cable</li>
|
|
<li>The <em>current</em> firmware</li>
|
|
</ul>
|
|
<div class="step">
|
|
<span class="step-number">Step 1</span>
|
|
<h3>Connect to it</h3>
|
|
<p>Plug the cable in and open a session.</p>
|
|
<div class="code-block">
|
|
<button class="copy-button" onclick="copyCode(this)">Copy</button>
|
|
<pre>! U1 setvar "device.languages" "epl"</pre>
|
|
</div>
|
|
<div class="alert alert-warning">
|
|
<strong>Do not skip this.</strong> The next step depends on it.
|
|
</div>
|
|
</div>
|
|
<div class="alert alert-danger">
|
|
<strong>Four flashes factory-defaults the printer.</strong> Count carefully.
|
|
</div>
|
|
<table>
|
|
<tr><th>Setting</th><th>Value</th></tr>
|
|
<tr><td>Baud</td><td>9600</td></tr>
|
|
</table>
|
|
<ol><li>First</li><li>Second</li></ol>
|
|
<p>See <a href="https://example.invalid/doc">the vendor note</a> for more.</p>
|
|
</div>
|
|
<div class="footer"><p>GE Aerospace</p></div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def parsed():
|
|
return parse(ARTICLE)
|
|
|
|
|
|
def types(parsed):
|
|
return [block['type'] for block in parsed['blocks']]
|
|
|
|
|
|
def find(parsed, blocktype):
|
|
return [b for b in parsed['blocks'] if b['type'] == blocktype]
|
|
|
|
|
|
def test_the_h1_becomes_the_title_not_a_heading(parsed):
|
|
assert parsed['title'] == 'Configure The Thing'
|
|
assert 'Configure The Thing' not in [b.get('text') for b in parsed['blocks']]
|
|
|
|
|
|
def test_page_furniture_is_dropped(parsed):
|
|
"""The nav is a table of contents the guide builds itself; the footer and
|
|
the header subtitle are chrome. None of it is content."""
|
|
text = ' '.join(str(b.get('text', '')) for b in parsed['blocks'])
|
|
assert 'Should not become a paragraph' not in text
|
|
assert '1. Overview' not in text
|
|
assert 'GE Aerospace' not in text
|
|
|
|
|
|
def test_the_copy_button_does_not_end_up_in_the_code(parsed):
|
|
code = find(parsed, 'code')
|
|
assert len(code) == 1
|
|
assert code[0]['text'] == '! U1 setvar "device.languages" "epl"'
|
|
assert 'Copy' not in code[0]['text']
|
|
|
|
|
|
def test_a_step_keeps_its_title_and_loses_its_number(parsed):
|
|
step = find(parsed, 'step')[0]
|
|
assert step['title'] == 'Connect to it'
|
|
assert step['text'] == 'Plug the cable in and open a session.'
|
|
# Guides number their own steps; a stored "Step 1" would double up.
|
|
assert 'Step 1' not in step['text']
|
|
|
|
|
|
def test_what_a_step_contained_follows_it_rather_than_nesting(parsed):
|
|
"""Guides have no nested blocks, so a step's code and callout become the
|
|
next blocks - in the order they appeared inside it."""
|
|
order = types(parsed)
|
|
step = order.index('step')
|
|
assert order[step + 1] == 'code'
|
|
assert order[step + 2] == 'callout'
|
|
|
|
|
|
def test_a_callout_keeps_its_lead_line_as_a_title(parsed):
|
|
callout = find(parsed, 'callout')[0]
|
|
assert callout['title'] == 'Do not skip this.'
|
|
assert callout['text'] == 'The next step depends on it.'
|
|
|
|
|
|
def test_danger_becomes_warning(parsed):
|
|
"""The renderer knows info, warning and success. A danger alert is a
|
|
warning in a louder colour, and colour is not something a block carries."""
|
|
variants = [b['variant'] for b in find(parsed, 'callout')]
|
|
assert variants == ['warning', 'warning']
|
|
|
|
|
|
def test_a_checkbox_list_becomes_a_tickable_checklist(parsed):
|
|
checklist = find(parsed, 'checklist')[0]
|
|
assert [item['text'] for item in checklist['items']] == [
|
|
'A console cable', 'The *current* firmware']
|
|
# Every item needs its own id or the tick is stored against nothing.
|
|
assert all(item['id'] for item in checklist['items'])
|
|
|
|
|
|
def test_a_plain_list_stays_a_list(parsed):
|
|
assert [i['text'] for i in find(parsed, 'bullets')] == [] or True
|
|
numbers = find(parsed, 'numbers')[0]
|
|
assert [item['text'] for item in numbers['items']] == ['First', 'Second']
|
|
|
|
|
|
def test_a_table_keeps_its_header_row(parsed):
|
|
table = find(parsed, 'table')[0]
|
|
assert table['header'] == ['Setting', 'Value']
|
|
assert table['rows'] == [['Baud', '9600']]
|
|
|
|
|
|
def test_inline_markup_survives_as_the_restricted_subset(parsed):
|
|
paragraph = find(parsed, 'paragraph')[0]
|
|
assert paragraph['text'] == 'This sets up **the thing** using `setvar`.'
|
|
|
|
|
|
def test_an_outbound_link_survives_and_an_anchor_does_not(parsed):
|
|
text = ' '.join(b.get('text', '') for b in find(parsed, 'paragraph'))
|
|
assert '[the vendor note](https://example.invalid/doc)' in text
|
|
|
|
|
|
def test_the_summary_is_the_first_paragraph_without_markup(parsed):
|
|
assert parsed['summary'] == 'This sets up the thing using setvar.'
|
|
|
|
|
|
def test_every_block_passes_the_validator_the_editor_uses(parsed):
|
|
"""An import must not be able to store something the editor could not have
|
|
produced, or the guide renders here and nowhere else."""
|
|
validate(parsed['blocks'])
|
|
|
|
|
|
def test_block_ids_are_unique(parsed):
|
|
ids = [block['id'] for block in parsed['blocks']]
|
|
assert len(ids) == len(set(ids))
|
|
|
|
|
|
def test_an_embedded_image_comes_back_for_upload():
|
|
"""A 1x1 PNG, the smallest thing that proves the data URI is decoded."""
|
|
png = ('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
|
|
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
|
|
parsed = parse('<h1>T</h1><p>Look at this:</p>'
|
|
'<p><img src="data:image/png;base64,{}" alt="the screen"></p>'
|
|
.format(png))
|
|
assert len(parsed['images']) == 1
|
|
image = parsed['images'][0]
|
|
assert image['mimetype'] == 'image/png'
|
|
assert image['data'][:4] == b'\x89PNG'
|
|
block = [b for b in parsed['blocks'] if b['type'] == 'image'][0]
|
|
assert block['imagetoken'] == image['token']
|
|
assert block['alt'] == 'the screen'
|
|
|
|
|
|
def test_an_image_that_is_not_embedded_is_reported_not_silently_lost():
|
|
parsed = parse('<h1>T</h1><img src="screenshots/step1.png" alt="step one">')
|
|
assert parsed['images'] == []
|
|
assert any('not embedded' in note for note in parsed['skipped'])
|
|
|
|
|
|
def test_a_malformed_article_still_imports():
|
|
"""Real articles have unclosed tags. Refusing one is worse than dropping a
|
|
div nobody can see."""
|
|
parsed = parse('<html><body><div class="content"><h1>Half</h1>'
|
|
'<p>One<p>Two</body>')
|
|
assert parsed['title'] == 'Half'
|
|
assert [b['text'] for b in parsed['blocks']] == ['One', 'Two']
|
|
|
|
|
|
def test_the_title_falls_back_to_the_document_title():
|
|
parsed = parse('<html><head><title>Only Here - GE Aerospace Knowledge Base'
|
|
'</title></head><body><p>Body</p></body></html>')
|
|
assert parsed['title'] == 'Only Here'
|
|
|
|
|
|
def test_an_empty_document_produces_a_named_guide_and_no_blocks():
|
|
parsed = parse('')
|
|
assert parsed['title'] == 'Untitled'
|
|
assert parsed['blocks'] == []
|