diff --git a/run_imposition.py b/run_imposition.py index a9976ef..35b7a65 100644 --- a/run_imposition.py +++ b/run_imposition.py @@ -3,6 +3,7 @@ from pyodide.ffi import JsProxy from imposition.book import Book from imposition.rendition import Rendition +from imposition.dom import PyodideDOMAdapter async def main() -> None: print("Starting Python script") @@ -11,7 +12,8 @@ async def main() -> None: epub_bytes: bytes = epub_bytes_proxy.to_py() book: Book = Book(epub_bytes) - rendition: Rendition = Rendition(book, "viewer") + dom_adapter = PyodideDOMAdapter() + rendition: Rendition = Rendition(book, dom_adapter, "viewer") js.window.rendition = rendition diff --git a/src/imposition/dom.py b/src/imposition/dom.py new file mode 100644 index 0000000..4ea1bfc --- /dev/null +++ b/src/imposition/dom.py @@ -0,0 +1,46 @@ +from typing import Protocol, Any, Callable + +from js import document +from pyodide.ffi import create_proxy as pyodide_create_proxy +from pyodide.ffi import JsProxy + +class DOMElement(Protocol): + """A protocol for DOM elements.""" + style: Any + innerHTML: str + textContent: str + href: str + onclick: Callable[[Any], None] + onload: str + src: str + + def appendChild(self, child: "DOMElement") -> None: + ... + + def setAttribute(self, name: str, value: str) -> None: + ... + + def preventDefault(self) -> None: + ... + +class DOMAdapter(Protocol): + """A protocol for DOM operations.""" + def get_element_by_id(self, element_id: str) -> DOMElement: + ... + + def create_element(self, tag_name: str) -> DOMElement: + ... + + def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]: + ... + +class PyodideDOMAdapter: + """An implementation of the DOMAdapter protocol using Pyodide.""" + def get_element_by_id(self, element_id: str) -> JsProxy: + return document.getElementById(element_id) + + def create_element(self, tag_name: str) -> JsProxy: + return document.createElement(tag_name) + + def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]: + return pyodide_create_proxy(handler) diff --git a/src/imposition/rendition.py b/src/imposition/rendition.py index ad6aeab..a7dfccb 100644 --- a/src/imposition/rendition.py +++ b/src/imposition/rendition.py @@ -1,13 +1,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable, Optional -from js import document -from pyodide.ffi import create_proxy, JsProxy import xml.etree.ElementTree as ET import base64 import posixpath import mimetypes +from .dom import DOMAdapter, DOMElement + if TYPE_CHECKING: from .book import Book @@ -20,21 +20,24 @@ class Rendition: chapters, and provides navigation between them. """ - def __init__(self, book: Book, target_id: str) -> None: + def __init__(self, book: Book, dom_adapter: DOMAdapter, target_id: str) -> None: """ Initializes the Rendition object. :param book: An initialized Book object. :type book: Book + :param dom_adapter: An adapter for DOM operations. + :type dom_adapter: DOMAdapter :param target_id: The ID of the HTML element where the EPUB content will be rendered. :type target_id: str """ self.book: Book = book + self.dom_adapter: DOMAdapter = dom_adapter self.target_id: str = target_id - self.target_element: JsProxy = document.getElementById(self.target_id) + self.target_element: DOMElement = self.dom_adapter.get_element_by_id(self.target_id) self.current_chapter_index: int = 0 - self.iframe: JsProxy = document.createElement('iframe') + self.iframe: DOMElement = self.dom_adapter.create_element('iframe') self.iframe.style.width = '100%' self.iframe.style.height = '100%' self.iframe.style.border = 'none' @@ -43,25 +46,25 @@ def display_toc(self) -> None: """ Renders the table of contents into the 'toc-container' element. """ - toc_container: JsProxy = document.getElementById('toc-container') + toc_container: DOMElement = self.dom_adapter.get_element_by_id('toc-container') toc_container.innerHTML = '' - ul: JsProxy = document.createElement('ul') + ul: DOMElement = self.dom_adapter.create_element('ul') for item in self.book.toc: - li: JsProxy = document.createElement('li') - a: JsProxy = document.createElement('a') + li: DOMElement = self.dom_adapter.create_element('li') + a: DOMElement = self.dom_adapter.create_element('a') a.href = '#' a.textContent = item['title'] # Define a handler function to be proxied - def create_handler(url: str) -> Callable[[JsProxy], None]: - def handler(event: JsProxy) -> None: + def create_handler(url: str) -> Callable[[DOMElement], None]: + def handler(event: DOMElement) -> None: event.preventDefault() self.display(url) return handler # Create a proxy for the onclick event handler - a.onclick = create_proxy(create_handler(item['url'])) + a.onclick = self.dom_adapter.create_proxy(create_handler(item['url'])) li.appendChild(a) ul.appendChild(li) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 0000000..7fd4e45 --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,39 @@ +from typing import Any, Callable, Dict, List, Optional +from unittest.mock import Mock, MagicMock + +class MockDOMElement: + """A mock DOM element for testing.""" + def __init__(self, tag_name: str) -> None: + self.tag_name: str = tag_name + self.children: List[MockDOMElement] = [] + self.attributes: Dict[str, str] = {} + self.style = MagicMock() + self.innerHTML: str = "" + self.textContent: str = "" + self.href: str = "#" + self.onclick: Optional[Callable[[Any], None]] = None + self.onload: str = "" + self.src: str = "" + self.preventDefault: Mock = Mock() + + def appendChild(self, child: "MockDOMElement") -> None: + self.children.append(child) + + def setAttribute(self, name: str, value: str) -> None: + self.attributes[name] = value + +class MockDOMAdapter: + """A mock DOM adapter for testing.""" + def __init__(self) -> None: + self.elements: Dict[str, MockDOMElement] = {} + + def get_element_by_id(self, element_id: str) -> MockDOMElement: + if element_id not in self.elements: + self.elements[element_id] = MockDOMElement("div") + return self.elements[element_id] + + def create_element(self, tag_name: str) -> MockDOMElement: + return MockDOMElement(tag_name) + + def create_proxy(self, handler: Callable[..., Any]) -> Callable[..., Any]: + return handler diff --git a/tests/test_integration.py b/tests/test_integration.py index 5c9410e..da303b8 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -57,10 +57,12 @@ async def test_main_application_flow(mock_js_object): # Patch the `js` object and the `Rendition` class within the run_imposition module with patch('run_imposition.js', mock_js_object), \ patch('run_imposition.Book', autospec=True) as mock_book_class, \ + patch('run_imposition.PyodideDOMAdapter', autospec=True) as mock_dom_adapter_class, \ patch('run_imposition.Rendition', autospec=True) as mock_rendition_class: # Get the mock instances that will be created by the script mock_book_instance = mock_book_class.return_value + mock_dom_adapter_instance = mock_dom_adapter_class.return_value # Give the mock book a spine so the script can access it mock_book_instance.spine = ["chapter1.xhtml"] mock_rendition_instance = mock_rendition_class.return_value @@ -78,12 +80,15 @@ async def test_main_application_flow(mock_js_object): epub_content = mock_js_object.pyfetch.return_value.bytes.return_value.to_py() mock_book_class.assert_called_once_with(epub_content) - # 3. Verify that the Rendition object was instantiated with the book and viewer ID - mock_rendition_class.assert_called_once_with(mock_book_instance, "viewer") + # 3. Verify that the PyodideDOMAdapter was instantiated + mock_dom_adapter_class.assert_called_once_with() - # 4. Verify that the rendition instance was attached to the mock window + # 4. Verify that the Rendition object was instantiated with the book and viewer ID + mock_rendition_class.assert_called_once_with(mock_book_instance, mock_dom_adapter_instance, "viewer") + + # 5. Verify that the rendition instance was attached to the mock window assert mock_js_object.window.rendition is mock_rendition_instance - # 5. Verify that the TOC and the first chapter were displayed + # 6. Verify that the TOC and the first chapter were displayed mock_rendition_instance.display_toc.assert_called_once_with() mock_rendition_instance.display.assert_called_once_with("chapter1.xhtml") diff --git a/tests/test_rendition.py b/tests/test_rendition.py index 60c22ca..b1da979 100644 --- a/tests/test_rendition.py +++ b/tests/test_rendition.py @@ -1,18 +1,11 @@ -import sys from unittest.mock import MagicMock, patch import xml.etree.ElementTree as ET import pytest -# Mock the 'js' and 'pyodide' modules before importing the code that uses them -sys.modules["js"] = MagicMock() -sys.modules["pyodide"] = MagicMock() -sys.modules["pyodide.ffi"] = MagicMock() - - -# Since we've mocked the js module, we can now import the Rendition class -from imposition.rendition import Rendition # noqa: E402 -from imposition.book import Book # noqa: E402 +from imposition.rendition import Rendition +from imposition.book import Book +from tests.mocks import MockDOMAdapter @pytest.fixture @@ -31,119 +24,99 @@ def mock_book(): @pytest.fixture -def mock_document(): - """Fixture to create a mock of the global `document` object.""" - target_element = MagicMock(name="target_element") - toc_container = MagicMock(name="toc_container") - iframe = MagicMock(name="iframe") - ul = MagicMock(name="ul") - - document_mock = MagicMock(name="document") - - def get_element_by_id(id_): - if id_ == "viewer": - return target_element - if id_ == "toc-container": - return toc_container - return MagicMock() - document_mock.getElementById.side_effect = get_element_by_id - - def create_element(tag): - # Access call_count from the mock, not the function itself - call_count = document_mock.createElement.call_count - if tag == 'iframe': - return iframe - if tag == 'ul': - return ul - # Return a new mock for other elements to avoid side effects - element_mock = MagicMock(name=f"{tag}_{call_count}") - element_mock.appendChild.return_value = element_mock - return element_mock - document_mock.createElement.side_effect = create_element - - return document_mock - -def test_rendition_initialization(mock_book, mock_document): +def mock_dom_adapter(): + """Fixture to create a MockDOMAdapter instance.""" + return MockDOMAdapter() + + +def test_rendition_initialization(mock_book, mock_dom_adapter): """Test that the Rendition class initializes correctly.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - assert rendition.book is mock_book - assert rendition.target_id == "viewer" - mock_document.getElementById.assert_called_with("viewer") - assert rendition.target_element is not None - assert rendition.iframe is not None - -def test_display_toc(mock_book, mock_document): + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + assert rendition.book is mock_book + assert rendition.dom_adapter is mock_dom_adapter + assert rendition.target_id == "viewer" + assert rendition.target_element is not None + assert rendition.iframe is not None + assert rendition.iframe.tag_name == 'iframe' + + +def test_display_toc(mock_book, mock_dom_adapter): """Test that the display_toc method correctly generates the TOC.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - rendition.display_toc() + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + rendition.display_toc() + + toc_container = mock_dom_adapter.get_element_by_id("toc-container") + assert toc_container.innerHTML == '' + assert len(toc_container.children) == 1 + + ul_element = toc_container.children[0] + assert ul_element.tag_name == 'ul' + assert len(ul_element.children) == len(mock_book.toc) - toc_container = mock_document.getElementById("toc-container") - ul_element = mock_document.createElement('ul') + # Check the generated links + li_element = ul_element.children[0] + a_element = li_element.children[0] + assert a_element.textContent == "Chapter 1" + assert a_element.href == "#" - # Check that the container was cleared and the final UL was appended - assert toc_container.innerHTML == '' - toc_container.appendChild.assert_called_once_with(ul_element) -def test_display_first_chapter(mock_book, mock_document): +def test_display_first_chapter(mock_book, mock_dom_adapter): """Test displaying the first chapter by default.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - rendition.display() - mock_book.zip_file.read.assert_called_once_with(mock_book.spine[0]) - assert "data:text/html;base64," in rendition.iframe.src + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + rendition.display() + mock_book.zip_file.read.assert_called_once_with(mock_book.spine[0]) + assert "data:text/html;base64," in rendition.iframe.src -def test_display_specific_chapter(mock_book, mock_document): + +def test_display_specific_chapter(mock_book, mock_dom_adapter): """Test displaying a specific chapter by URL.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - chapter_url = "OEBPS/chapter2.xhtml" - rendition.display(chapter_url) - mock_book.zip_file.read.assert_called_once_with(chapter_url) - assert "data:text/html;base64," in rendition.iframe.src - -def test_display_with_anchor(mock_book, mock_document): + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + chapter_url = "OEBPS/chapter2.xhtml" + rendition.display(chapter_url) + mock_book.zip_file.read.assert_called_once_with(chapter_url) + assert "data:text/html;base64," in rendition.iframe.src + + +def test_display_with_anchor(mock_book, mock_dom_adapter): """Test that displaying a chapter with an anchor sets the iframe onload property.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - chapter_url = "OEBPS/chapter1.xhtml#section1" - rendition.display(chapter_url) - assert rendition.iframe.onload == "this.contentWindow.location.hash = '#section1'" + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + chapter_url = "OEBPS/chapter1.xhtml#section1" + rendition.display(chapter_url) + assert rendition.iframe.onload == "this.contentWindow.location.hash = '#section1'" + -def test_next_chapter(mock_book, mock_document): +def test_next_chapter(mock_book, mock_dom_adapter): """Test navigating to the next chapter.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - rendition.current_chapter_index = 0 - with patch.object(rendition, 'display') as mock_display: - rendition.next_chapter() - assert rendition.current_chapter_index == 1 - mock_display.assert_called_once_with(mock_book.spine[1]) - -def test_previous_chapter(mock_book, mock_document): + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + rendition.current_chapter_index = 0 + with patch.object(rendition, 'display') as mock_display: + rendition.next_chapter() + assert rendition.current_chapter_index == 1 + mock_display.assert_called_once_with(mock_book.spine[1]) + + +def test_previous_chapter(mock_book, mock_dom_adapter): """Test navigating to the previous chapter.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - rendition.current_chapter_index = 1 - with patch.object(rendition, 'display') as mock_display: - rendition.previous_chapter() - assert rendition.current_chapter_index == 0 - mock_display.assert_called_once_with(mock_book.spine[0]) - -def test_embed_asset(mock_book, mock_document): + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + rendition.current_chapter_index = 1 + with patch.object(rendition, 'display') as mock_display: + rendition.previous_chapter() + assert rendition.current_chapter_index == 0 + mock_display.assert_called_once_with(mock_book.spine[0]) + + +def test_embed_asset(mock_book, mock_dom_adapter): """Test that _embed_asset correctly creates a data URI.""" - with patch("imposition.rendition.document", mock_document): - rendition = Rendition(mock_book, "viewer") - element = ET.Element('img', {'src': '../images/cover.jpg'}) - chapter_path = "OEBPS/chapter1.xhtml" - # This is the expected path after normalization - expected_asset_path = "images/cover.jpg" - - mock_book.zip_file.read.return_value = b'fake image data' - with patch('mimetypes.guess_type', return_value=('image/jpeg', None)) as mock_guess_type: - rendition._embed_asset(element, 'src', chapter_path) - - mock_guess_type.assert_called_once_with(expected_asset_path) - mock_book.zip_file.read.assert_called_once_with(expected_asset_path) - assert element.get('src').startswith('data:image/jpeg;base64,') + rendition = Rendition(mock_book, mock_dom_adapter, "viewer") + element = ET.Element('img', {'src': '../images/cover.jpg'}) + chapter_path = "OEBPS/chapter1.xhtml" + # This is the expected path after normalization + expected_asset_path = "images/cover.jpg" + + mock_book.zip_file.read.return_value = b'fake image data' + with patch('mimetypes.guess_type', return_value=('image/jpeg', None)) as mock_guess_type: + rendition._embed_asset(element, 'src', chapter_path) + + mock_guess_type.assert_called_once_with(expected_asset_path) + mock_book.zip_file.read.assert_called_once_with(expected_asset_path) + assert element.get('src').startswith('data:image/jpeg;base64,')