Files
feresaria/scripts/tests/test_wiki.py
Samuel Onoja eb84104940 Publish topic guides to the wiki and document tabbed windows
Publish the guides from this repository to the GitHub Wiki. Organize them into Usage, Configuration and Development, with 15 separate configuration topics and redirects for the former website guide URLs.

Add a tabbed-windows guide covering grouping, switching tabs and taking windows out. Document the scrolling indicator's defaults and limits, and link the guide from the README, shortcuts and IPC pages. Update from main, including the latest tabbed-layout changes.

Separate Activities setup, configuration and publishing documentation. Review the guide prose with the unslop skill, keeping existing examples and heading links. The wiki workflow uses GitHub's built-in token to publish documentation changes on main.

Validation:

- 20 documentation tests pass, including README links and Activities/tab-indicator field coverage.
- The publisher builds 46 wiki pages; the site build and Python syntax checks pass.
- All 115 code examples from main are preserved.
- The Python suite passes: 90 tests passed and 31 opt-in integration tests skipped, with the system GI module available.
2026-10-13 00:32:23 +01:00

272 lines
15 KiB
Python

"""Wiki publication must preserve examples and resolve links before writing."""
import importlib.util
import json
import re
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
PARSER_AVAILABLE = importlib.util.find_spec('markdown_it') is not None
if PARSER_AVAILABLE:
spec = importlib.util.spec_from_file_location('wiki', ROOT / 'scripts/build-wiki.py')
wiki = importlib.util.module_from_spec(spec)
spec.loader.exec_module(wiki)
@unittest.skipUnless(PARSER_AVAILABLE, 'Install site/requirements.txt for wiki checks')
class WikiTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
(self.root / 'docs').mkdir()
self.source = self.root / 'docs/intro.md'
self.source.write_text('# Intro\n\n## Local heading\n')
self.config = self.root / 'docs/configuration.md'
self.config.write_text('# Configuration\n\n## With `code` & punctuation!\n\n## Again\n\n## Again\n')
self.pages = {self.source: 'Intro', self.config: 'Configuration'}
self.patch = mock.patch.object(wiki, 'ROOT', self.root)
self.patch.start()
self.addCleanup(self.patch.stop)
def rewrite(self, text):
return wiki.rewrite(text, self.source, self.pages, 'deadbeef')
def test_guide_links_and_duplicate_heading_anchors(self):
text = '[Reference](configuration.md#with-code--punctuation) and [again](configuration.md#again-1).\n'
result = self.rewrite(text)
self.assertIn(wiki.WIKI + '/Configuration#with-code--punctuation', result)
self.assertIn(wiki.WIKI + '/Configuration#again-1', result)
def test_local_anchor_and_external_link_unchanged(self):
text = '[Here](#local-heading) and [Elsewhere](https://example.org/a?q=1#fragment).\n'
self.assertEqual(self.rewrite(text), text)
def test_source_files_and_images_are_pinned_to_source_commit(self):
(self.root / 'docs/image.webp').write_bytes(b'image')
(self.root / 'example.kdl').write_text('layout {}')
result = self.rewrite('![Desktop](image.webp) [Example](../example.kdl)\n')
self.assertIn(wiki.REPO + '/raw/deadbeef/docs/image.webp', result)
self.assertIn(wiki.REPO + '/blob/deadbeef/example.kdl', result)
def test_fenced_indented_and_inline_code_are_preserved(self):
example = '[Config](configuration.md)'
text = f'{example}\n\n```md\n{example}\n```\n\n {example}\n\nUse `{example}` as an example.\n'
result = self.rewrite(text)
self.assertIn(wiki.WIKI + '/Configuration', result)
self.assertIn(f'```md\n{example}\n```', result)
self.assertIn(f' {example}', result)
self.assertIn(f'`{example}`', result)
def test_reference_links_and_angle_destinations(self):
result = self.rewrite('[Config][ref]\n\n[ref]: <configuration.md> "Title"\n')
self.assertIn('[ref]: <' + wiki.WIKI + '/Configuration> "Title"', result)
def test_missing_file_missing_anchor_and_unpublished_markdown_fail(self):
for target in ('missing.md', 'configuration.md#missing'):
with self.subTest(target=target), self.assertRaises(ValueError):
self.rewrite('[Missing](' + target + ')')
(self.root / 'unpublished.md').write_text('# Unpublished')
with self.assertRaises(ValueError):
self.rewrite('[Missing](../unpublished.md)')
def test_failed_validation_does_not_replace_any_destination_page(self):
destination = self.root / 'published'
destination.mkdir()
(destination / 'Home.md').write_text('Previously published')
with mock.patch.object(sys, 'argv', ['build-wiki.py', '--output', str(destination)]), mock.patch.object(wiki, 'render', side_effect=ValueError('bad link')), mock.patch.object(wiki.subprocess, 'check_output', return_value='deadbeef'):
with self.assertRaises(ValueError):
wiki.main()
self.assertEqual((destination / 'Home.md').read_text(), 'Previously published')
self.assertEqual(list(destination.iterdir()), [destination / 'Home.md'])
def test_stale_generated_pages_removed_and_manual_pages_preserved(self):
destination = self.root / 'published'
wiki.write_pages({'Old.md': '# Old', 'Home.md': '# Home'}, destination)
(destination / 'Manual.md').write_text('Manual page')
wiki.write_pages({'Home.md': '# New home'}, destination)
self.assertFalse((destination / 'Old.md').exists())
self.assertEqual((destination / 'Manual.md').read_text(), 'Manual page')
self.assertEqual(json.loads((destination / wiki.MANIFEST).read_text()), ['Home.md'])
def test_manifest_cannot_delete_files_outside_destination(self):
destination = self.root / 'published'
destination.mkdir()
(destination / wiki.MANIFEST).write_text('["../keep.md"]')
(self.root / 'keep.md').write_text('Keep')
with self.assertRaises(ValueError):
wiki.write_pages({'Home.md': '# New'}, destination)
self.assertEqual((self.root / 'keep.md').read_text(), 'Keep')
self.assertFalse((destination / 'Home.md').exists())
def test_repeated_generation_has_identical_content(self):
destination = self.root / 'published'
pages = {'Home.md': '# Home', '_Sidebar.md': '[Home](Home)'}
wiki.write_pages(pages, destination)
before = {p.name: p.read_bytes() for p in destination.iterdir()}
wiki.write_pages(pages, destination)
self.assertEqual(before, {p.name: p.read_bytes() for p in destination.iterdir()})
def test_every_repository_guide_is_published_and_navigable(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
sources = wiki.guide_sources()
sidebar = output['_Sidebar.md']
self.assertEqual([line for line in sidebar.splitlines() if line.startswith('## ')], ['## Usage', '## Configuration', '## Development'])
self.assertNotIn('Wiki-Publishing.md', output)
self.assertNotIn('Getting-Started.md', output)
for source in sources:
name = wiki.page_name(source)
with self.subTest(source=source):
self.assertIn(name + '.md', output)
if name != 'Home':
self.assertIn(wiki.WIKI + '/' + name, sidebar)
for name, content in output.items():
for href in wiki.destinations(content):
self.assertFalse(href.endswith('.md') and not href.startswith('https://'), (name, href))
def test_configuration_topics_have_distinct_pages_and_direct_navigation(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
for path in (ROOT / 'docs/configuration').glob('*.md'):
with self.subTest(topic=path.name):
page = wiki.page_name(path)
self.assertTrue(page.startswith('Configuration-'))
self.assertIn(page + '.md', output)
self.assertIn(wiki.WIKI + '/' + page + ')', output['_Sidebar.md'])
self.assertIn('## Layout', output['Configuration.md'])
self.assertNotIn('default-column-width', output['Configuration.md'])
self.assertIn('default-column-width', output['Configuration-Layout.md'])
self.assertIn('### Binding modes', output['Configuration.md'])
self.assertIn('## Binding modes', output['Configuration-Bindings.md'])
self.assertIn(wiki.WIKI + '/Configuration-Bindings', output['Input-and-Bindings.md'])
def test_activity_settings_have_separate_configuration_page(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
settings = output['Configuration-Activities.md']
usage = output['Live-Activities.md']
sidebar_configuration = output['_Sidebar.md'].split('## Configuration', 1)[1].split('## Development', 1)[0]
self.assertIn(wiki.WIKI + '/Configuration-Activities)', sidebar_configuration)
self.assertIn('shell-hook #true', settings)
self.assertIn('rule "cargo"', settings)
self.assertIn('## Command rules', settings)
self.assertNotIn('rule "cargo"', usage)
self.assertIn('## Configure Activities', usage)
self.assertIn(wiki.WIKI + '/Configuration-Activities#command-rules', usage)
self.assertIn('## Start using it', usage)
self.assertIn('## Publisher ownership and lifecycle', usage)
def test_tabbed_windows_usage_and_settings_are_published_separately(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
usage = output['Tabbed-Windows.md']
layout = output['Configuration-Layout.md']
bindings = output['Configuration-Bindings.md']
navigation = output['_Sidebar.md'].split('## Usage', 1)[1].split('## Configuration', 1)[0]
self.assertIn(wiki.WIKI + '/Tabbed-Windows)', navigation)
self.assertIn('segmented rail', usage)
self.assertIn('feresectl join-tab left', usage)
self.assertIn(wiki.WIKI + '/Configuration-Layout#tabbed-columns', usage)
self.assertIn('default-column-display "tabbed"', layout)
self.assertNotIn('default-column-display', output['Configuration.md'])
for action in ('toggle-column-tabbed', 'join-tab', 'leave-tab', 'next-tab', 'previous-tab'):
with self.subTest(action=action):
self.assertIn('`' + action + '`', bindings)
for page in ('Shortcuts-and-Gestures.md', 'IPC.md', 'Configuration-Bindings.md'):
self.assertIn(wiki.WIKI + '/Tabbed-Windows', output[page])
def test_tab_indicator_reference_documents_every_configuration_field(self):
source = (ROOT / 'compositor/src/config/tab_indicator.rs').read_text()
reference = (ROOT / 'docs/configuration/layout.md').read_text()
body = re.search(r'struct TabIndicatorConfig\s*\{([^}]+)\}', source).group(1)
for field in re.findall(r'^\s+(\w+):', body, re.MULTILINE):
with self.subTest(field=field):
self.assertIn('| `' + field.replace('_', '-') + '` |', reference)
def test_activity_publishing_has_its_own_development_page(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
developer = output['Development-Activities.md']
usage = output['Live-Activities.md']
navigation = output['_Sidebar.md'].split('## Development', 1)[1]
self.assertIn(wiki.WIKI + '/Development-Activities)', navigation)
self.assertIn('Linux 6.5', developer)
self.assertIn('socket-bound pidfd', ' '.join(developer.split()))
self.assertNotIn('socket-bound pidfd', usage)
for anchor in ('publish-your-own-status', 'publisher-ownership-and-lifecycle', 'extensions-and-templates'):
with self.subTest(anchor=anchor):
self.assertIn(anchor, wiki.heading_ids(usage))
self.assertIn(wiki.WIKI + '/Development-Activities#' + anchor, usage)
self.assertIn(anchor, wiki.heading_ids(developer))
def test_activity_reference_documents_every_configuration_field(self):
source = (ROOT / 'crates/ferese-config/src/activities.rs').read_text()
reference = (ROOT / 'docs/configuration/activities.md').read_text()
special = {'name': 'Rule name', 'pattern': '`match`', 'sources': '## Sources', 'rules': '## Command rules', 'result': '`result.ok`'}
for name in ('Settings', 'Sources', 'Rule', 'ResultText'):
body = re.search(r'pub struct ' + name + r'\s*\{([^}]+)\}', source).group(1)
for field in re.findall(r'pub (\w+):', body):
label = special.get(field, '`' + ('result.' if name == 'ResultText' else '') + field.replace('_', '-') + '`')
with self.subTest(struct=name, field=field):
self.assertIn(label, reference)
def test_readme_wiki_links_resolve_to_published_pages(self):
from html.parser import HTMLParser
from urllib.parse import urlsplit
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.hrefs = set()
def handle_starttag(self, tag, attrs):
if tag == 'a':
self.hrefs.update(value for key, value in attrs if key == 'href')
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
readme = (ROOT / 'README.md').read_text()
links = Links()
links.feed(readme)
for href in wiki.destinations(readme) | links.hrefs:
if not href.startswith(wiki.WIKI):
continue
relative = href[len(wiki.WIKI):].lstrip('/')
url = urlsplit(relative)
page = (url.path or 'Home') + '.md'
with self.subTest(href=href):
self.assertIn(page, output)
if url.fragment:
self.assertIn(url.fragment, wiki.heading_ids(output[page]))
def test_legacy_configuration_anchors_point_to_published_topics(self):
with mock.patch.object(wiki, 'ROOT', ROOT):
output = wiki.render('deadbeef')
introduction = output['Configuration.md']
for href in wiki.destinations(introduction):
prefix = wiki.WIKI + '/Configuration-'
if not href.startswith(prefix):
continue
page, _, anchor = href[len(wiki.WIKI) + 1:].partition('#')
self.assertIn(page + '.md', output)
if anchor:
self.assertIn(anchor, wiki.heading_ids(output[page + '.md']))
self.assertIn(wiki.WIKI + '/Configuration-Bindings#binding-modes', introduction)
def test_homepage_redirects_keep_old_paths_and_fragments(self):
subprocess.run([sys.executable, str(ROOT / 'scripts/build-site.py')], check=True, capture_output=True)
destination = ROOT / 'build/site/docs/configuration.html'
content = destination.read_text()
self.assertIn(wiki.WIKI + '/Configuration', content)
self.assertIn('location.hash', content)
self.assertIn('http-equiv="refresh"', content)
self.assertIn('<a href="' + wiki.WIKI + '/Configuration">', content)
self.assertTrue((ROOT / 'build/site/assets/screenshots/ferese-media.webp').is_file())
self.assertIn(wiki.WIKI + '/Configuration-Appearance', (ROOT / 'build/site/docs/configuration/appearance.html').read_text())
if __name__ == '__main__':
unittest.main()