It struck the heading and left everything underneath looking untouched, which is the opposite of what somebody ticking "this section is done" means. The strike now covers every block under that heading, down to the next heading of the same or higher rank - so a top-level section takes its sub-sections with it. That needed the rank, and the HTML importer was throwing it away: h2, h3 and h4 all arrived as a flat 'heading', so a parent heading had no children to cross out. It was the visible half of a real loss - a converted article kept its words and lost its shape. Headings now carry their level and render at it, and a guide written before this reads as flat, exactly as it did. Code blocks, tables and images inside a finished section fade but are not struck. A line through a command makes it unreadable, and re-reading the command is usually the reason to look back at a step already done. The five imported guides were re-imported so they carry their structure.
226 lines
8.3 KiB
Python
226 lines
8.3 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'] == []
|
|
|
|
|
|
def test_a_heading_carries_its_rank():
|
|
"""Ticking a section off has to cross out what belongs to it, and nothing
|
|
records that a sub-heading belongs to the heading above unless the level
|
|
travels with them."""
|
|
parsed = parse('<h1>T</h1><h2>Credentials</h2><p>Intro.</p>'
|
|
'<h3>SFLD share</h3><p>Detail.</p><h2>Next</h2>')
|
|
headings = [(b['text'], b.get('level'))
|
|
for b in parsed['blocks'] if b['type'] == 'heading']
|
|
assert headings == [('Credentials', 2), ('SFLD share', 3), ('Next', 2)]
|