Files
odysseus/tests/test_document_rich_text_tools.py
T

466 lines
18 KiB
Python

"""Regression guards for the Rich Text document editor toolset."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOC_JS = (ROOT / "static/js/document.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static/style.css").read_text(encoding="utf-8")
def test_rich_text_toolbar_exposes_document_formatting_controls():
for control in (
'data-dd="font"',
'data-dd="textsize"',
'data-dd="align"',
'data-dd="color"',
'data-dd="highlight"',
'data-md="superscript"',
'data-md="subscript"',
'data-dd="table"',
'data-md="unlink"',
'data-md="removeformat"',
'data-dd="spacing"',
):
assert control in DOC_JS
def test_heading_menu_includes_normal_text_and_all_heading_levels():
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
"function initMdToolbar", 1
)[0]
assert "['paragraph', 'Paragraph', 'P']" in dropdown
for level in range(1, 7):
assert f"['h{level}', 'Heading {level}', 'H{level}']" in dropdown
assert "document.execCommand('formatBlock', false, 'p')" in DOC_JS
assert "/^h[1-6]$/.test(action)" in DOC_JS
assert "h4: '#### '" in DOC_JS
assert ".doc-email-richbody h4" in STYLE
def test_line_spacing_menu_exposes_common_document_intervals():
for action in (
"linespacing:normal",
"linespacing:1",
"linespacing:1.15",
"linespacing:1.5",
"linespacing:2",
):
assert action in DOC_JS
def test_line_spacing_preserves_selection_and_uses_one_undoable_snapshot():
spacing = DOC_JS.split("const _richSpacingBlockSelector", 1)[1].split(
"function _focusRichTextOffset", 1
)[0]
assert "function _richSelectionTextOffsets(root)" in spacing
assert "function _restoreRichSelectionTextOffsets(root, offsets)" in spacing
assert "function _richClosestSpacingBlock(root, node)" in spacing
assert "function _richSelectedSpacingBlockIndexes(root)" in spacing
assert "document.createTreeWalker(root, NodeFilter.SHOW_TEXT)" in spacing
assert "const clone = rich.cloneNode(true)" in spacing
assert "block.style.lineHeight = value" in spacing
assert "block.style.removeProperty('line-height')" in spacing
assert "document.execCommand('insertHTML', false, clone.innerHTML)" in spacing
assert "_restoreRichSelectionTextOffsets(rich, offsets)" in spacing
def test_rich_text_commands_sync_and_schedule_save():
apply_format = DOC_JS.split("function applyMdFormat(action)", 1)[1]
command_branch = apply_format.split("const _rich = _emailRichbodyActive();", 1)[1].split(
"const ta = document.getElementById('doc-editor-textarea');", 1
)[0]
assert "fontName" in command_branch
assert "fontSize" in command_branch
assert "_applyRichTableAction(_rich, action)" in command_branch
assert "document.execCommand('insertHTML'" in DOC_JS
assert "_syncEmailRichbody(_rich);" in command_branch
assert "_scheduleEmailRichbodySave();" in command_branch
def test_rich_text_paste_uses_document_allowlist_and_drops_embedded_media():
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
assert "const allowedTags = new Set" in paste_cleaner
assert "'TABLE'" in paste_cleaner
assert "querySelectorAll('img, video, audio, canvas')" in paste_cleaner
assert "el.replaceWith(...Array.from(el.childNodes))" in paste_cleaner
assert "keepLink" in paste_cleaner
def test_empty_table_is_preserved_and_visually_editable():
assert "rich.querySelector('img, hr, table')" in DOC_JS
assert "clone.insertRow(-1)" in DOC_JS
assert "row.insertCell(-1)" in DOC_JS
assert ".doc-email-richbody.richtext-mode table" in STYLE
assert ".doc-email-richbody.richtext-mode th" in STYLE
def test_table_menu_exposes_insert_and_contextual_operations():
for action in (
"table:insert:2:2",
"table:insert:3:3",
"table:insert:4:4",
"table:toggle-header-row",
"table:toggle-header-column",
"table:cell-align:top",
"table:cell-align:middle",
"table:cell-align:bottom",
"table:row-above",
"table:row-below",
"table:column-left",
"table:column-right",
"table:delete-row",
"table:delete-column",
"table:delete",
):
assert action in DOC_JS
assert 'data-dd="table"' in DOC_JS
assert "_richSelectionCell(rich)" in DOC_JS
def test_table_mutations_are_undoable_and_restore_the_caret():
table_section = DOC_JS.split("function _replaceRichTable", 1)[1].split(
"function applyMdFormat", 1
)[0]
assert "document.execCommand('insertHTML'" in table_section
assert "_focusRichTableCell(rich, cell)" in table_section
assert "clone.deleteRow(rowIndex)" in table_section
assert "row.deleteCell(cellIndex)" in table_section
def test_temporary_table_tokens_are_not_persisted():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
assert "data-editor-(?:table|checklist|image|inline-code|link)-token" in sanitizer
assert ".replace(" in sanitizer
def test_rich_text_checklists_support_conversion_and_checked_state():
checklist = DOC_JS.split("function _richSelectionChecklistItem", 1)[1].split(
"function _cleanRichTextPasteHtml", 1
)[0]
assert "function _toggleRichChecklist(rich)" in checklist
assert "function _setRichChecklistItemChecked" in checklist
assert "document.execCommand('insertUnorderedList')" in checklist
assert "document.execCommand('insertHTML'" in checklist
assert "item.dataset.checked" in checklist
assert "aria-checked" in checklist
def test_checklist_interactions_cover_pointer_and_keyboard_users():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
assert "rich.addEventListener('pointerdown'" in rich_wiring
assert "mod && key === 'enter'" in rich_wiring
assert "_setRichChecklistItemChecked" in rich_wiring
def test_checklist_markup_survives_paste_save_and_export():
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
assert "keepChecklistClass" in paste_cleaner
assert "keepChecklistState" in paste_cleaner
assert "ul.rich-checklist>li[data-checked" in DOC_JS
assert ".doc-email-richbody.richtext-mode ul.rich-checklist" in STYLE
def test_email_list_menu_does_not_offer_app_specific_checklists():
dropdown = DOC_JS.split("function _showMdDropdown", 1)[1].split(
"function initMdToolbar", 1
)[0]
assert "['check', 'Checklist'" in dropdown
assert "activeDoc?.language === 'email'" in dropdown
assert "action !== 'check'" in dropdown
def test_rich_text_image_menu_exposes_management_controls():
for control in (
'data-dd="image"',
"image:size:auto",
"image:size:100",
"image:size:60",
"image:size:35",
"image:align:left",
"image:align:center",
"image:align:right",
"image:caption",
"image:alt",
"image:delete",
):
assert control in DOC_JS
def test_rich_text_image_insertion_and_edits_are_undoable():
insertion = DOC_JS.split("function _insertRichTextImages", 1)[1].split(
"async function _uploadMarkdownImages", 1
)[0]
replacement = insertion.split("function _replaceRichImage", 1)[1].split(
"function _deleteRichImage", 1
)[0]
assert "document.execCommand('insertHTML'" in insertion
assert "range.insertNode" not in insertion
assert "range.selectNode(original)" in replacement
assert "replacement.outerHTML" in replacement
assert "range.selectNode(original.closest('figure" not in replacement
def test_rich_text_image_selection_markers_are_not_persisted():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
assert "(?:table|checklist|image|inline-code|link)-token" in sanitizer
assert "data-editor-image-selected" in sanitizer
def test_rich_text_image_styles_are_available_in_editor_and_export():
assert "img.richtext-image-size-60" in STYLE
assert "img.richtext-image-align-center" in STYLE
assert "img.richtext-image[data-editor-image-selected]" in STYLE
assert "img.richtext-image-size-60" in DOC_JS
assert "img.richtext-image-align-center" in DOC_JS
assert "figure.richtext-image .richtext-image-caption" in STYLE
assert "figure.richtext-image .richtext-image-caption" in DOC_JS
def test_existing_figure_wrapped_images_are_normalized_on_load():
normalizer = DOC_JS.split("function _normalizeRichTextImages", 1)[1].split(
"function _clearRichImageSelection", 1
)[0]
assert "figure.richtext-image" in normalizer
assert "image.classList.add('richtext-image')" in normalizer
assert "richtext-image-size-" in normalizer
assert "richtext-image-align-" in normalizer
def test_inline_code_and_code_blocks_use_distinct_rich_text_commands():
apply_format = DOC_JS.split("function applyMdFormat(action)", 1)[1]
rich_branch = apply_format.split("const _rich = _emailRichbodyActive();", 1)[1].split(
"const ta = document.getElementById('doc-editor-textarea');", 1
)[0]
assert "_toggleRichInlineCode(_rich)" in rich_branch
assert "action === 'codeblock'" in rich_branch
assert "document.execCommand('formatBlock'" in rich_branch
def test_inline_code_supports_selection_toggle_and_future_typing():
inline_code = DOC_JS.split("function _richSelectionInlineCode", 1)[1].split(
"function _cleanRichTextPasteHtml", 1
)[0]
assert "function _toggleRichInlineCode(rich)" in inline_code
assert "document.execCommand('removeFormat')" in inline_code
assert "document.execCommand('styleWithCSS', false, true)" in inline_code
assert "document.execCommand('fontName'" in inline_code
assert "OdysseusInlineCode" in inline_code
assert "document.execCommand('insertHTML'" in inline_code
def test_inline_code_live_marker_is_saved_as_semantic_code():
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
assert "_isRichInlineCodeMarker(span)" in sanitizer
assert "document.createElement('code')" in sanitizer
assert "span.replaceWith(code)" in sanitizer
def test_rich_code_shortcuts_and_active_state_are_wired():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
assert "action = 'codeblock'" in rich_wiring
assert "action = 'code'" in rich_wiring
assert "_richInlineCodeTypingArmed" in rich_wiring
assert "_richSelectionInlineCode(rich)" in rich_wiring
def test_rich_code_styles_are_scoped_and_exported():
assert ".doc-email-richbody.richtext-mode code" in STYLE
assert 'span[style*="OdysseusInlineCode"]' in STYLE
assert ".doc-email-richbody.richtext-mode pre" in STYLE
assert "'code{padding:" in DOC_JS
assert "'pre code{padding:0" in DOC_JS
def test_rich_link_dialog_supports_edit_open_and_remove_actions():
link_dialog = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
"function _promptImageAlt", 1
)[0]
assert "editing ? 'Edit link' : 'Insert link'" in link_dialog
assert 'id="doc-link-open"' in link_dialog
assert 'id="doc-link-remove"' in link_dialog
assert "editing ? 'Save' : 'Insert'" in link_dialog
assert "action: 'open'" in link_dialog
assert "action: 'remove'" in link_dialog
assert ".doc-link-remove-btn" in STYLE
def test_rich_links_validate_protocols_during_paste_save_and_editing():
normalizer = DOC_JS.split("function _normalizeRichLinkUrl", 1)[1].split(
"function _promptLink", 1
)[0]
sanitizer = DOC_JS.split("function _sanitizedRichTextHtml", 1)[1].split(
"function _richTextContentToHtml", 1
)[0]
paste_cleaner = DOC_JS.split("function _cleanRichTextPasteHtml", 1)[1].split(
"function _wireEmailRichbody", 1
)[0]
assert "https?:|mailto:|tel:" in normalizer
assert "https:${url}" in normalizer
assert "https://${url}" in normalizer
assert "_normalizeRichLinkUrl(link.getAttribute('href'))" in sanitizer
assert "link.replaceWith(...Array.from(link.childNodes))" in sanitizer
assert "_normalizeRichLinkUrl(el.getAttribute('href'))" in paste_cleaner
assert "'H4'" in paste_cleaner
def test_rich_link_edits_and_removal_use_native_undoable_commands():
link_commands = DOC_JS.split("function _richLinkAtRange", 1)[1].split(
"function _richSelectionCell", 1
)[0]
assert "function _removeRichLink(rich, link)" in link_commands
assert "document.execCommand('insertHTML', false, link.innerHTML)" in link_commands
assert "existingLink.cloneNode(true)" in link_commands
assert "savedRange.cloneContents()" in link_commands
assert "document.execCommand('insertHTML', false, a.outerHTML)" in link_commands
assert "inserted.removeAttribute('data-editor-link-token')" in link_commands
assert "window.open(res.url, '_blank', 'noopener,noreferrer')" in link_commands
def test_link_toolbar_toggles_link_when_selection_is_already_linked():
rich_wiring = DOC_JS.split("function _wireEmailRichbody", 1)[1].split(
"function _emailRichbodyActive", 1
)[0]
assert "const currentLink = _richLinkAtRange(rich, selectionRange)" in rich_wiring
assert "set('[data-md=\"link\"]', !!currentLink)" in rich_wiring
assert "const existingLink = _richLinkAtRange(_rich, range)" in DOC_JS
assert "if (!_removeRichLink(_rich, existingLink)) return" in DOC_JS
assert "data-md=\"unlink\"" in DOC_JS
def test_rich_text_exports_include_structural_styles():
assert "function _richTextExportCss()" in DOC_JS
assert "<style>${_richTextExportCss()}</style>" in DOC_JS
assert "style.textContent = _richTextExportCss();" in DOC_JS
def test_header_history_controls_persist_rich_text_changes():
history = DOC_JS.split("// Undo button in header", 1)[1].split(
"// Diff toggle button", 1
)[0]
assert "doc-undo-btn" in history
assert "doc-redo-btn" in history
assert "document.execCommand('undo')" in history
assert "document.execCommand('redo')" in history
assert history.count("_scheduleEmailRichbodySave();") >= 2
def test_find_uses_dom_ranges_for_visible_rich_text():
find_section = DOC_JS.split("// ── In-document find (Ctrl+F) ──", 1)[1].split(
"// Delete (or Backspace)", 1
)[0]
assert "function _buildRichFindRanges" in find_section
assert "document.createTreeWalker(rich, NodeFilter.SHOW_TEXT)" in find_section
assert "document.createRange()" in find_section
assert "CSS.highlights.set(_richFindAllName" in find_section
assert "CSS.highlights.set(_richFindCurrentName" in find_section
assert "if (rich)" in find_section
assert find_section.index("if (rich)") < find_section.index("const text = ta.value")
def test_rich_find_highlights_are_non_persistent_and_cleaned_up():
assert "::highlight(doc-find-results)" in STYLE
assert "::highlight(doc-find-current)" in STYLE
assert "CSS.highlights?.delete('doc-find-results')" in DOC_JS
assert "CSS.highlights?.delete('doc-find-current')" in DOC_JS
def test_find_and_replace_has_visible_controls_and_keyboard_entry_points():
for control in (
'id="doc-find-toolbar-btn"',
'id="doc-find-replace-toggle"',
'id="doc-replace-input"',
'id="doc-replace-current"',
'id="doc-replace-all"',
):
assert control in DOC_JS
assert "const key = e.key.toLowerCase()" in DOC_JS
assert "key === 'h'" in DOC_JS
assert "_openFindBar(key === 'h')" in DOC_JS
assert ".doc-replace-row[hidden]" in STYLE
assert ".doc-find-action" in STYLE
def test_rich_find_replace_preserves_structure_and_uses_native_undo_commands():
replace_section = DOC_JS.split("function _replaceAllLiteral", 1)[1].split(
"function _doFind", 1
)[0]
assert "function _replaceFindCurrent()" in replace_section
assert "document.execCommand('insertText'" in replace_section
assert "function _replaceFindAll()" in replace_section
assert "const clone = rich.cloneNode(true)" in replace_section
assert "[...ranges].reverse()" in replace_section
assert "document.execCommand('insertHTML', false, clone.innerHTML)" in replace_section
assert "_syncEmailRichbody(rich)" in replace_section
assert "_scheduleEmailRichbodySave()" in replace_section
def test_source_find_replace_uses_literal_matching_and_shared_undo_path():
replace_section = DOC_JS.split("function _replaceAllLiteral", 1)[1].split(
"function _doFind", 1
)[0]
assert "lowerText.indexOf(lowerQuery, cursor)" in replace_section
assert "_replaceRange(ta, match, match + query.length" in replace_section
assert "_replaceRange(ta, 0, ta.value.length, result.text)" in replace_section
assert "pos = i + Math.max(1, q.length)" in DOC_JS
def test_visible_rich_text_selection_feeds_ai_editing_context():
selection_section = DOC_JS.split("// ---- Selection-based AI editing ----", 1)[1].split(
"// ── Inline Suggestion Comments", 1
)[0]
assert "function updateRichSelectionState(rich)" in selection_section
assert "kind: 'rich'" in selection_section
assert "function _richRangeFromOffsets" in selection_section
assert "CSS.highlights.set(_richSelectionHighlightName" in selection_section
assert "const source = s.kind === 'rich' ? richText : text;" in selection_section
assert "rich.addEventListener('mouseup'" in DOC_JS
def test_rich_selection_highlight_is_cleared_without_mutating_document_html():
assert "::highlight(doc-ai-selections)" in STYLE
assert "CSS.highlights?.delete(_richSelectionHighlightName)" in DOC_JS
assert "CSS.highlights?.delete('doc-ai-selections')" in DOC_JS