From 21e779d281e810d7afa18f4a4318a62caba0ce85 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Sun, 11 Jan 2026 23:41:20 +0100 Subject: [PATCH] Add comprehensive tests for layout and rendering components, enhancing coverage for layout lines, blocks, and document structures --- tests/test_layout.py | 251 +++++++++++++++++++++++++++++++++++++++---- tests/test_render.py | 162 ++++++++++++++++++++++++---- 2 files changed, 373 insertions(+), 40 deletions(-) diff --git a/tests/test_layout.py b/tests/test_layout.py index 4960115..80dc303 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -1,58 +1,273 @@ """Tests for layout components.""" import pytest -from unittest.mock import Mock -from src.layout.document import DocumentLayout -from src.layout.block import BlockLayout, LineLayout -from src.layout.inline import TextLayout +import sys +from unittest.mock import Mock, patch, MagicMock + +# Mock skia before importing layout modules +mock_skia = MagicMock() +mock_font = MagicMock() +mock_font.measureText = lambda text: len(text) * 7.0 # ~7 pixels per char +mock_typeface = MagicMock() +mock_skia.Typeface.MakeDefault.return_value = mock_typeface +mock_skia.Font.return_value = mock_font +sys.modules['skia'] = mock_skia + +from src.layout.document import DocumentLayout, LayoutLine, LayoutBlock +from src.layout.block import BlockLayout, LineLayout, build_block_layout +from src.layout.inline import TextLayout, InlineLayout from src.parser.html import Element, Text +class TestLayoutLine: + """Tests for LayoutLine class.""" + + def test_layout_line_creation(self): + line = LayoutLine("Hello", 20, 100, 14) + assert line.text == "Hello" + assert line.x == 20 + assert line.y == 100 + assert line.font_size == 14 + + def test_layout_line_with_char_positions(self): + char_positions = [0.0, 5.0, 10.0, 15.0, 20.0, 25.0] + line = LayoutLine("Hello", 20, 100, 14, char_positions) + assert line.char_positions == char_positions + + def test_layout_line_height(self): + line = LayoutLine("Test", 0, 0, 14) + # Height should be based on linespace (font_size * 1.4) + assert line.height == pytest.approx(14 * 1.4, rel=0.1) + + +class TestLayoutBlock: + """Tests for LayoutBlock class.""" + + def test_layout_block_creation(self): + block = LayoutBlock("p") + assert block.tag == "p" + assert block.block_type == "block" + assert block.lines == [] + + def test_layout_block_with_type(self): + block = LayoutBlock("li", "list-item") + assert block.block_type == "list-item" + + class TestDocumentLayout: + """Tests for DocumentLayout class.""" + def test_document_layout_creation(self): node = Element("html") layout = DocumentLayout(node) assert layout.node is node - assert layout.children == [] + assert layout.blocks == [] + assert layout.lines == [] + + def test_document_layout_finds_body(self): + # Create HTML structure: html > body > p + html = Element("html") + body = Element("body") + p = Element("p") + p.children = [Text("Hello world")] + body.children = [p] + html.children = [body] - def test_document_layout(self): - node = Element("html") + layout = DocumentLayout(html) + lines = layout.layout(800) + + assert len(lines) > 0 + assert any("Hello world" in line.text for line in lines) + + def test_document_layout_returns_empty_without_body(self): + node = Element("div") + node.children = [] layout = DocumentLayout(node) - result = layout.layout(800, 1.0) - assert result == 800.0 + lines = layout.layout(800) + assert lines == [] + + def test_document_layout_handles_headings(self): + body = Element("body") + h1 = Element("h1") + h1.children = [Text("Title")] + body.children = [h1] + + layout = DocumentLayout(body) + lines = layout.layout(800) + + assert len(lines) == 1 + assert lines[0].text == "Title" + assert lines[0].font_size == 24 # h1 font size + + def test_document_layout_handles_paragraphs(self): + body = Element("body") + p = Element("p") + p.children = [Text("Paragraph text")] + body.children = [p] + + layout = DocumentLayout(body) + lines = layout.layout(800) + + assert len(lines) == 1 + assert lines[0].text == "Paragraph text" + assert lines[0].font_size == 14 # p font size + + def test_document_layout_handles_lists(self): + body = Element("body") + ul = Element("ul") + li = Element("li") + li.children = [Text("List item")] + ul.children = [li] + body.children = [ul] + + layout = DocumentLayout(body) + lines = layout.layout(800) + + assert len(lines) == 1 + assert "•" in lines[0].text # Bullet prefix + assert "List item" in lines[0].text + + def test_document_layout_word_wrapping(self): + body = Element("body") + p = Element("p") + # Long text that should wrap + p.children = [Text("This is a very long paragraph that should wrap to multiple lines when the width is narrow enough")] + body.children = [p] + + layout = DocumentLayout(body) + lines = layout.layout(200) # Narrow width to force wrapping + + assert len(lines) > 1 # Should wrap to multiple lines + + def test_document_layout_char_positions(self): + body = Element("body") + p = Element("p") + p.children = [Text("Hello")] + body.children = [p] + + layout = DocumentLayout(body) + lines = layout.layout(800) + + assert len(lines) == 1 + # char_positions should have len(text) + 1 entries (including start at 0) + assert len(lines[0].char_positions) == 6 # "Hello" = 5 chars + 1 + assert lines[0].char_positions[0] == 0.0 class TestBlockLayout: + """Tests for BlockLayout class.""" + def test_block_layout_creation(self): node = Element("div") layout = BlockLayout(node) assert layout.node is node assert layout.children == [] - + def test_block_layout_with_parent(self): parent_node = Element("body") child_node = Element("div") parent_layout = BlockLayout(parent_node) child_layout = BlockLayout(child_node, parent=parent_layout) assert child_layout.parent is parent_layout + + def test_block_layout_stores_dimensions(self): + node = Element("div") + layout = BlockLayout(node) + layout.x = 10 + layout.y = 20 + layout.width = 100 + layout.height = 50 + assert layout.x == 10 + assert layout.y == 20 + assert layout.width == 100 + assert layout.height == 50 class TestLineLayout: + """Tests for LineLayout class.""" + def test_line_layout_creation(self): - node = Element("span") - layout = LineLayout(node) - assert layout.node is node + layout = LineLayout() + assert layout.words == [] + assert layout.x == 0 + assert layout.y == 0 + + def test_line_layout_add_word(self): + layout = LineLayout() + layout.add_word("Hello", 0, 14) + layout.add_word("World", 50, 14) + assert len(layout.words) == 2 class TestTextLayout: + """Tests for TextLayout class.""" + def test_text_layout_creation(self): node = Text("Hello") layout = TextLayout(node, "Hello") assert layout.node is node assert layout.word == "Hello" - - def test_text_layout_length(self): + + def test_text_layout_layout_returns_width(self): + node = Text("Test") + layout = TextLayout(node, "Test") + width = layout.layout(14) + assert width > 0 + + def test_text_layout_dimensions(self): + node = Text("Hi") + layout = TextLayout(node, "Hi") + layout.x = 5 + layout.y = 10 + assert layout.x == 5 + assert layout.y == 10 + + +class TestInlineLayout: + """Tests for InlineLayout class.""" + + def test_inline_layout_creation(self): node = Text("Hello") - layout = TextLayout(node, "Hello") - result = layout.layout() - assert result == 5 + layout = InlineLayout(node) + assert layout.node is node + assert layout.children == [] + + def test_inline_layout_add_word(self): + node = Text("Hello World") + layout = InlineLayout(node) + layout.add_word("Hello", 14) + layout.add_word("World", 14) + assert len(layout.children) == 2 + + def test_inline_layout_layout(self): + node = Text("Hello World") + layout = InlineLayout(node) + layout.add_word("Hello", 14) + layout.add_word("World", 14) + lines = layout.layout(0, 0, 1000, 14) + # Both words should fit on one line with wide width + assert len(lines) == 1 + + +class TestBuildBlockLayout: + """Tests for build_block_layout factory function.""" + + def test_build_block_layout_from_element(self): + node = Element("p") + node.children = [Text("Test paragraph")] + result = build_block_layout(node) + assert result is not None + assert result.node is node + assert result.font_size == 14 + + def test_build_block_layout_heading(self): + node = Element("h1") + node.children = [Text("Heading")] + result = build_block_layout(node, font_size=24) + assert result.font_size == 24 + + def test_build_block_layout_list_item(self): + node = Element("li") + node.children = [Text("Item")] + result = build_block_layout(node, block_type="list-item", bullet=True) + assert result.block_type == "list-item" diff --git a/tests/test_render.py b/tests/test_render.py index e59e515..9ab2239 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,34 +1,114 @@ """Tests for rendering primitives.""" import pytest -from src.render.paint import PaintCommand, DrawText +import sys +from unittest.mock import Mock, patch, MagicMock + +# Mock skia before importing render modules +mock_skia = MagicMock() +mock_font = MagicMock() +mock_font.measureText = lambda text: len(text) * 7.0 # ~7 pixels per char +mock_typeface = MagicMock() +mock_skia.Typeface.MakeDefault.return_value = mock_typeface +mock_skia.Font.return_value = mock_font +sys.modules['skia'] = mock_skia + +from src.render.paint import PaintCommand, DrawText, DrawRect, DisplayList from src.render.composite import CompositedLayer -from src.render.fonts import get_font, linespace +from src.render.fonts import FontCache, get_font, measure_text, linespace class TestPaintCommands: + """Tests for paint command base class.""" + def test_paint_command_creation(self): cmd = PaintCommand((0, 0, 100, 100)) assert cmd.rect == (0, 0, 100, 100) - + + +class TestDrawText: + """Tests for DrawText paint command.""" + def test_draw_text_creation(self): - cmd = DrawText(10, 20, "Hello", ("Arial", 12), "black") + cmd = DrawText(10, 20, "Hello", 14) + assert cmd.x == 10 + assert cmd.y == 20 assert cmd.text == "Hello" - assert cmd.font == ("Arial", 12) - assert cmd.color == "black" + assert cmd.font_size == 14 + + def test_draw_text_with_color(self): + cmd = DrawText(0, 0, "Test", 12, color=0xFF0000) + assert cmd.color == 0xFF0000 + + def test_draw_text_rect_property(self): + cmd = DrawText(10, 20, "Hi", 14) + # Should have a rect based on position and size + assert cmd.rect is not None + assert cmd.rect[0] == 10 # x + + +class TestDrawRect: + """Tests for DrawRect paint command.""" + + def test_draw_rect_creation(self): + cmd = DrawRect(10, 20, 110, 70, color=0x000000) + assert cmd.rect == (10, 20, 110, 70) + + def test_draw_rect_with_color(self): + cmd = DrawRect(0, 0, 50, 50, color=0x00FF00) + assert cmd.color == 0x00FF00 + + def test_draw_rect_fill_mode(self): + cmd_fill = DrawRect(0, 0, 50, 50, color=0x000000, fill=True) + cmd_stroke = DrawRect(0, 0, 50, 50, color=0x000000, fill=False) + assert cmd_fill.fill is True + assert cmd_stroke.fill is False + + +class TestDisplayList: + """Tests for DisplayList class.""" + + def test_display_list_creation(self): + dl = DisplayList() + assert len(dl.commands) == 0 + + def test_display_list_append(self): + dl = DisplayList() + cmd = DrawText(0, 0, "Test", 14) + dl.append(cmd) + assert len(dl.commands) == 1 + assert dl.commands[0] is cmd + + def test_display_list_len(self): + dl = DisplayList() + dl.append(DrawText(0, 0, "A", 14)) + dl.append(DrawText(0, 20, "B", 14)) + assert len(dl) == 2 + + def test_display_list_iteration(self): + dl = DisplayList() + cmd1 = DrawText(0, 0, "A", 14) + cmd2 = DrawText(0, 20, "B", 14) + dl.append(cmd1) + dl.append(cmd2) + + items = list(dl) + assert items == [cmd1, cmd2] class TestCompositedLayer: + """Tests for composited layer.""" + def test_composited_layer_creation(self): layer = CompositedLayer() assert layer.items == [] - + def test_composited_layer_with_item(self): item = "mock_item" layer = CompositedLayer(item) assert len(layer.items) == 1 assert layer.items[0] == item - + def test_add_item(self): layer = CompositedLayer() layer.add("item1") @@ -36,20 +116,58 @@ class TestCompositedLayer: assert len(layer.items) == 2 -class TestFonts: +class TestFontCache: + """Tests for FontCache singleton.""" + + def test_font_cache_singleton(self): + cache1 = FontCache() + cache2 = FontCache() + assert cache1 is cache2 + + def test_font_cache_get_font(self): + cache = FontCache() + font1 = cache.get_font(14) + font2 = cache.get_font(14) + # Should return the same cached font + assert font1 is font2 + + def test_font_cache_different_sizes(self): + cache = FontCache() + font14 = cache.get_font(14) + font18 = cache.get_font(18) + # Different sizes should be different font instances (but both are mocked) + # At minimum, both should be non-None + assert font14 is not None + assert font18 is not None + + +class TestFontFunctions: + """Tests for font module functions.""" + def test_get_font(self): font = get_font(14) - assert font == (14, "normal", "normal") - - def test_get_font_with_weight(self): - font = get_font(16, weight="bold") - assert font == (16, "bold", "normal") - - def test_get_font_with_style(self): - font = get_font(12, style="italic") - assert font == (12, "normal", "italic") - + assert font is not None + + def test_get_font_caching(self): + font1 = get_font(16) + font2 = get_font(16) + assert font1 is font2 + + def test_measure_text(self): + width = measure_text("Hello", 14) + assert width > 0 + assert isinstance(width, (int, float)) + + def test_measure_text_empty(self): + width = measure_text("", 14) + assert width == 0 + def test_linespace(self): - font = (14, "normal", "normal") - space = linespace(font) - assert space == int(14 * 1.2) + space = linespace(14) + # Should be font_size * 1.4 (typical line height) + assert space == pytest.approx(14 * 1.4, rel=0.1) + + def test_linespace_different_sizes(self): + space14 = linespace(14) + space20 = linespace(20) + assert space20 > space14