from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication from bouquin.editor import Editor from bouquin.theme import ThemeManager, ThemeConfig, Theme import re def _mk_editor() -> Editor: # pytest-qt ensures a QApplication exists app = QApplication.instance() tm = ThemeManager(app, ThemeConfig()) return Editor(tm) def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None: c = editor.textCursor() c.movePosition(QTextCursor.Start) while True: c2 = QTextCursor(c) c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) if c2.position() == c.position(): break fmt = c2.charFormat() if fmt.isImageFormat(): editor.setTextCursor(c2) return QTextImageFormat(fmt) c.movePosition(QTextCursor.Right) return None def _fmt_at(editor: Editor, pos: int): c = editor.textCursor() c.setPosition(pos) c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) return c.charFormat() def test_space_breaks_link_anchor_and_styling(qtbot): e = _mk_editor() e.resize(600, 300) e.show() qtbot.waitExposed(e) # Type a URL, which should be linkified (anchor + underline + blue) url = "https://mig5.net" QTest.keyClicks(e, url) qtbot.waitUntil(lambda: e.toPlainText() == url) # Sanity: characters within the URL are anchors for i in range(len(url)): assert _fmt_at(e, i).isAnchor() # Hit Space – Editor.keyPressEvent() should call _break_anchor_for_next_char() QTest.keyClick(e, Qt.Key_Space) # Type some normal text; it must not inherit the link formatting tail = "this is a test" QTest.keyClicks(e, tail) qtbot.waitUntil(lambda: e.toPlainText().endswith(tail)) txt = e.toPlainText() # Find where our 'tail' starts start = txt.index(tail) end = start + len(tail) # None of the trailing characters should be part of an anchor or visually underlined for i in range(start, end): fmt = _fmt_at(e, i) assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor" assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined" # Optional: ensure the HTML only wraps the URL in , not the trailing text html = e.document().toHtml() assert re.search( r']*href="https?://mig5\.net"[^>]*>(?:]*>)?https?://mig5\.net(?:)?\s+this is a test', html, re.S, ), html assert "this is a test" not in html def test_embed_qimage_saved_as_data_url(qtbot): e = _mk_editor() e.resize(600, 400) qtbot.addWidget(e) e.show() qtbot.waitExposed(e) img = QImage(60, 40, QImage.Format_ARGB32) img.fill(0xFF336699) e._insert_qimage_at_cursor(img, autoscale=False) html = e.to_html_with_embedded_images() assert "data:image/png;base64," in html def test_insert_images_autoscale_and_fit(qtbot, tmp_path): # Create a very wide image so autoscale triggers big = QImage(2000, 800, QImage.Format_ARGB32) big.fill(0xFF00FF00) big_path = tmp_path / "big.png" big.save(str(big_path)) e = _mk_editor() e.resize(420, 300) # known viewport width qtbot.addWidget(e) e.show() qtbot.waitExposed(e) e.insert_images([str(big_path)], autoscale=True) # Cursor lands after the image + a blank block; helper will select the image char fmt = _move_cursor_to_first_image(e) assert fmt is not None # After autoscale, width should be <= ~92% of viewport max_w = int(e.viewport().width() * 0.92) assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding # Now exercise "fit to editor width" e._fit_image_to_editor_width() _tc, fmt2, _orig = e._image_info_at_cursor() assert fmt2 is not None assert abs(fmt2.width() - max_w) <= 1 def test_linkify_trims_trailing_punctuation(qtbot): e = _mk_editor() qtbot.addWidget(e) e.show() qtbot.waitExposed(e) e.setPlainText("See (https://example.com).") # Wait until linkification runs (connected to textChanged) qtbot.waitUntil(lambda: 'href="' in e.document().toHtml()) html = e.document().toHtml() # Anchor should *not* include the closing ')' assert 'href="https://example.com"' in html assert 'href="https://example.com)."' not in html def test_code_block_enter_exits_on_empty_line(qtbot): from PySide6.QtCore import Qt from PySide6.QtGui import QTextCursor from PySide6.QtTest import QTest from bouquin.editor import Editor e = _mk_editor() qtbot.addWidget(e) e.show() qtbot.waitExposed(e) e.setPlainText("code") c = e.textCursor() c.select(QTextCursor.BlockUnderCursor) e.setTextCursor(c) e.apply_code() # Put caret at end of the code block, then Enter to create an empty line *inside* the frame c = e.textCursor() c.movePosition(QTextCursor.EndOfBlock) e.setTextCursor(c) QTest.keyClick(e, Qt.Key_Return) # Ensure we are on an empty block *inside* the code frame qtbot.waitUntil( lambda: e._find_code_frame(e.textCursor()) is not None and e.textCursor().block().length() == 1 ) # Second Enter should jump *out* of the frame QTest.keyClick(e, Qt.Key_Return) # qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None) class DummyMenu: def __init__(self): self.seps = 0 self.subs = [] self.exec_called = False def addSeparator(self): self.seps += 1 def addMenu(self, title): m = DummyMenu() self.subs.append((title, m)) return m def addAction(self, *a, **k): pass def exec(self, *a, **k): self.exec_called = True def _themes(): app = QApplication.instance() return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) def test_context_menu_adds_image_actions(monkeypatch, qtbot): e = Editor(_themes()) qtbot.addWidget(e) # Fake an image at cursor qi = QImage(10, 10, QImage.Format_ARGB32) qi.fill(0xFF00FF00) imgfmt = QTextImageFormat() imgfmt.setName("x") imgfmt.setWidth(10) imgfmt.setHeight(10) tc = e.textCursor() monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi)) dummy = DummyMenu() monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy) class Evt: def globalPos(self): return QPoint(0, 0) e.contextMenuEvent(Evt()) assert dummy.exec_called assert dummy.seps == 1 assert any(t == "Image size" for t, _ in dummy.subs) def test_insert_from_mime_image_and_urls(tmp_path, qtbot): e = Editor(_themes()) qtbot.addWidget(e) # Build a mime with an image mime = QMimeData() img = QImage(6, 6, QImage.Format_ARGB32) img.fill(0xFF0000FF) mime.setImageData(img) e.insertFromMimeData(mime) html = e.document().toHtml() assert "