Files
shopdb-flask/tests/test_plugins/test_guides.py
cproudlock 38d1e367fc Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some
keywords pointing at a document held somewhere else. That leaves the documents
themselves as HTML files on a share: no search, no history, no way to know
whether the copy pinned to a noticeboard is current, and no way for a technician
to tick off where they are.

A guide is stored as an ordered list of typed blocks rather than a blob of
HTML, because three of the four things this feature exists for depend on it. A
tickable step needs a stable id to be remembered; dark mode needs semantic
markup rather than pasted colours; and print needs to know where a step begins
so it does not split one across a page. Free HTML gives none of that and lets
pasted styling fight the theme.

Inline text is a restricted Markdown subset - bold, italic, inline code, links,
line breaks - escaped BEFORE formatting, so nothing an author pasted can reach
the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the
theme decides how it looks. That is what makes a guide written next year match
one written today, and dark mode work without an author thinking about it.

DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes
something writes an immutable revision; and a separate pointer says which
revision READERS get. Without that pointer, editing a live procedure changes it
under a technician who is halfway through following it. Publishing promotes an
existing revision rather than snapshotting, so a publish with unsaved edits
cannot ship a version nobody reviewed. Restoring writes a new revision with old
content, so the record of what people were told in between survives.

Saves dedup on a content hash covering title, summary, tags and blocks, so an
autosave that changed nothing writes no version - otherwise the history fills
with identical entries and hides the three edits that mattered.

Progress lives in the browser, not the database: shopfloor PCs share a login, so
per-user ticks would show one technician's progress to the next person on that
bay, which reads as "already done". Keyed by guide and version, because step 4
of version 3 is not step 4 of version 2.

Five permissions, and the split between edit and publish is the point: a
technician can write a procedure up while somebody else decides it is fit for
the floor. create-without-edit works too - an author may edit their own guide
until it is published, after which other people are following it.
2026-08-26 10:24:26 -04:00

335 lines
13 KiB
Python

"""Guides: block validation, revisioning, and the draft/publish split.
The behaviour worth protecting hardest is the last one. A technician following a
procedure on a shop floor must not have it change under them because somebody is
editing it upstairs, and the only thing standing between them and that is the
published pointer.
"""
import pytest
from shopdb import create_app
from shopdb.extensions import db as _db
from shopdb.plugins import plugin_manager
# ---------------------------------------------------------------------------
# Block validation - pure, no app context needed. These rules exist to stop a
# BAD SAVE becoming a guide that will not render weeks later, when the author
# has moved on and the only evidence is a blank page.
# ---------------------------------------------------------------------------
def _block(blockid='b1', blocktype='paragraph', **extra):
return dict({'id': blockid, 'type': blocktype}, **extra)
def test_a_well_formed_block_list_passes():
from plugins.guides.services.blocks import validate
blocks = [
_block('b1', 'heading', text='Before you start'),
_block('b2', 'paragraph', text='Check the **cable** first.'),
_block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]),
]
assert validate(blocks) == blocks
def test_a_block_without_an_id_is_refused():
"""Ids are what make a tick rememberable.
Without one the checkbox silently loses its state on the next visit, which
reads to a user as "the checkboxes do not work" - a bug report with no
stack trace and no obvious cause.
"""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([{'type': 'paragraph', 'text': 'no id'}])
assert 'stable id' in str(exc.value)
def test_duplicate_ids_are_refused():
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('same'), _block('same')])
assert 'duplicate' in str(exc.value)
def test_an_unknown_block_type_is_refused_and_lists_the_known_ones():
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1', 'carousel')])
message = str(exc.value)
assert 'carousel' in message and 'checklist' in message
def test_checklist_items_need_their_own_ids():
"""Same reason as blocks: the item id is what a tick is stored against."""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1', 'checklist', items=[{'text': 'no id'}])])
assert 'item' in str(exc.value)
def test_the_error_names_which_block_is_wrong():
"""An author fixing a rejected save should not have to count blocks."""
from plugins.guides.services.blocks import validate, BlockError
with pytest.raises(BlockError) as exc:
validate([_block('b1'), _block('b2'), {'type': 'paragraph'}])
assert str(exc.value).startswith('block 3:')
def test_content_hash_covers_title_and_tags_not_just_blocks():
"""Renaming or retagging a guide is a change a reader notices.
Hashing only the body would drop those edits out of the history entirely.
"""
from plugins.guides.services.blocks import contenthash
blocks = [_block('b1', 'paragraph', text='same body')]
base = contenthash('Title', 'Summary', 'pxe, share', blocks)
assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base
assert contenthash('Title', 'Summary', 'pxe', blocks) != base
assert contenthash('Title', 'Different', 'pxe, share', blocks) != base
def test_content_hash_ignores_tag_order_case_and_spacing():
"""Otherwise reordering tags in the editor would manufacture a version."""
from plugins.guides.services.blocks import contenthash
blocks = [_block()]
assert (contenthash('T', '', 'PXE, Share', blocks)
== contenthash('T', '', 'share ,pxe', blocks))
def test_checklist_item_count_counts_only_checklists():
from plugins.guides.services.blocks import checklistitemcount
blocks = [
_block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]),
_block('b2', 'bullets', items=['not tickable']),
_block('b3', 'checklist', items=[{'id': 'i3'}]),
]
assert checklistitemcount(blocks) == 3
def test_plaintext_flattens_a_guide_for_search():
"""People search for something written halfway down, not for the title."""
from plugins.guides.services.blocks import plaintext
blocks = [
_block('b1', 'heading', text='Mapped drive'),
_block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]),
_block('b3', 'table', rows=[['left', 'right']]),
]
flat = plaintext(blocks)
assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat
# ---------------------------------------------------------------------------
# Authoring: revisions, dedup and publishing. Needs an app context.
# ---------------------------------------------------------------------------
@pytest.fixture(scope='module')
def gd_app():
saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes)
application = create_app('testing')
from plugins.guides.plugin import GuidesPlugin
plugin = GuidesPlugin()
pm = application.extensions['plugin_manager']
if plugin.meta.api_prefix not in pm._registered_prefixes:
pm._register_plugin_components(plugin)
with application.app_context():
_db.create_all()
yield application
_db.session.remove()
_db.drop_all()
(plugin_manager._app, plugin_manager._db, plugin_manager.registry,
plugin_manager.loader, plugin_manager.migration_manager,
plugin_manager._registered_prefixes) = saved
@pytest.fixture
def gd_clean(gd_app):
from plugins.guides.models import Guide, GuideRevision
with gd_app.app_context():
_db.session.query(GuideRevision).delete()
_db.session.query(Guide).delete()
_db.session.commit()
yield
def _newguide(title='Replacing a marker controller'):
from plugins.guides.models import Guide
guide = Guide(title=title, slug=title.lower().replace(' ', '-'),
status='draft')
_db.session.add(guide)
_db.session.flush()
return guide
def test_the_first_save_creates_version_one(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
guide, revision = authoring.save(
guide, title='Replacing a marker controller',
blocks=[_block('b1', 'paragraph', text='Power it off first.')])
_db.session.commit()
assert revision is not None
assert revision.revision == 1
assert guide.revision == 1
def test_saving_identical_content_writes_no_revision(gd_app, gd_clean):
"""Autosave would otherwise produce a hundred versions a day.
A history full of identical entries hides the three edits that mattered,
which is the same failure the backups plugin dedups away.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
blocks = [_block('b1', 'paragraph', text='Unchanged.')]
authoring.save(guide, title='T', blocks=blocks)
_db.session.commit()
guide, second = authoring.save(guide, title='T', blocks=blocks)
_db.session.commit()
assert second is None, 'an unchanged save must not manufacture a version'
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
assert guide.revision == 1
def test_a_real_edit_creates_the_next_version(gd_app, gd_clean):
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='One.')])
_db.session.commit()
guide, second = authoring.save(
guide, blocks=[_block('b1', 'paragraph', text='Two.')])
_db.session.commit()
assert second.revision == 2
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2
def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean):
"""A half-written procedure on a shop floor is worse than no procedure."""
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block()])
_db.session.commit()
assert authoring.readable(guide) is None
def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean):
"""THE point of the draft/publish split.
A technician halfway through a procedure keeps reading the version that was
published, even while the next one is being written.
"""
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='Published text.')])
authoring.publish(guide)
_db.session.commit()
authoring.save(guide,
blocks=[_block('b1', 'paragraph', text='Work in progress.')])
_db.session.commit()
served = authoring.readable(guide)
assert served.blocks[0]['text'] == 'Published text.'
assert guide.revision == 2, 'the working copy moved on'
assert served.revision == 1, 'what readers get did not'
def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')])
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')])
authoring.publish(guide)
_db.session.commit()
assert authoring.readable(guide).blocks[0]['text'] == 'v2'
assert guide.status == 'published'
def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean):
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
_db.session.commit()
with pytest.raises(ValueError):
authoring.publish(guide)
def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean):
"""For a procedure that turns out to be wrong.
The versions stay, so it can be corrected and published again, and so there
is still a record of what people were being told in the meantime.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
authoring.save(guide, title='T', blocks=[_block()])
authoring.publish(guide)
_db.session.commit()
authoring.unpublish(guide)
_db.session.commit()
assert authoring.readable(guide) is None
assert guide.status == 'draft'
assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean):
"""A history you can edit is not a record of anything.
Restoring brings old content forward as a NEW version, so what was
published in between stays visible.
"""
from plugins.guides.models import GuideRevision
from plugins.guides.services import authoring
with gd_app.app_context():
guide = _newguide()
_, first = authoring.save(guide, title='T',
blocks=[_block('b1', 'paragraph', text='original')])
authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')])
_db.session.commit()
authoring.restore(guide, first)
_db.session.commit()
rows = (GuideRevision.query.filter_by(guideid=guide.guideid)
.order_by(GuideRevision.revision).all())
assert [r.revision for r in rows] == [1, 2, 3]
assert rows[2].blocks[0]['text'] == 'original'
assert 'Restored from version 1' in (rows[2].note or '')
assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record'
def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean):
"""A guide that will not render is a page a person cannot read.
An empty guide with its title and tags intact is a better failure than an
error page, and the corruption is still visible - the editor shows nothing.
"""
with gd_app.app_context():
guide = _newguide()
guide.blocksjson = 'not json at all'
_db.session.commit()
assert guide.blocks == []