tag closes any open
tag (HTML5 implicit paragraph closure)
+ if tag == "p" and self.current.tag == "p":
+ self._pop("p")
+
self._push(el)
def handle_endtag(self, tag):
@@ -193,7 +198,6 @@ def parse_html_with_styles(html_text: str, apply_styles: bool = True) -> Element
"""
from .css import parse as parse_css
from .style import StyleResolver
- import os
from pathlib import Path
# Parse HTML
diff --git a/src/render/fonts.py b/src/render/fonts.py
index d8c92f2..0ce0b83 100644
--- a/src/render/fonts.py
+++ b/src/render/fonts.py
@@ -23,7 +23,7 @@ class FontCache:
"""Cache for Skia fonts and typefaces."""
_instance = None
-
+
# Common emoji/symbol fonts to try as last resort before showing tofu
_EMOJI_FALLBACK_FONTS = (
'Noto Color Emoji',
@@ -51,7 +51,7 @@ class FontCache:
# This dramatically reduces cache entries and font lookups
is_emoji = text and self._is_emoji_char(text[0])
cache_key = (families, is_emoji)
-
+
if cache_key in self._typeface_cache:
return self._typeface_cache[cache_key]
@@ -60,7 +60,7 @@ class FontCache:
# Skip generic families that won't resolve to specific fonts
if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'):
continue
-
+
typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal())
if typeface and typeface.getFamilyName() == family:
# Font was actually found - check if it has glyphs for sample text
diff --git a/src/render/paint.py b/src/render/paint.py
index bb8bbc2..3ef91f6 100644
--- a/src/render/paint.py
+++ b/src/render/paint.py
@@ -61,7 +61,7 @@ class DrawRect(PaintCommand):
class DrawImage(PaintCommand):
"""Command to draw an image."""
- def __init__(self, x: float, y: float, width: float, height: float,
+ def __init__(self, x: float, y: float, width: float, height: float,
image: skia.Image, alt_text: str = ""):
super().__init__((x, y, x + width, y + height))
self.x = x
@@ -82,11 +82,11 @@ class DrawImage(PaintCommand):
if paint is None:
paint = skia.Paint()
paint.setAntiAlias(True)
-
+
# Calculate scale factor
scale_x = self.width / self.image.width()
scale_y = self.height / self.image.height()
-
+
# Use canvas transform for scaling
canvas.save()
canvas.translate(self.x, self.y)
@@ -99,7 +99,7 @@ class DrawImage(PaintCommand):
logger.error(f"Failed to draw image: {e}")
# If drawing fails, fall back to placeholder
self._draw_placeholder(canvas, paint)
-
+
def _draw_placeholder(self, canvas: skia.Canvas, paint: skia.Paint = None):
"""Draw a placeholder for a missing or failed image."""
if paint is None:
@@ -108,14 +108,14 @@ class DrawImage(PaintCommand):
paint.setStyle(skia.Paint.kFill_Style)
rect = skia.Rect.MakeLTRB(self.x, self.y, self.x + self.width, self.y + self.height)
canvas.drawRect(rect, paint)
-
+
# Draw border
border_paint = skia.Paint()
border_paint.setColor(skia.ColorGRAY)
border_paint.setStyle(skia.Paint.kStroke_Style)
border_paint.setStrokeWidth(1)
canvas.drawRect(rect, border_paint)
-
+
# Draw alt text if available
if self.alt_text:
text_paint = skia.Paint()
diff --git a/src/render/pipeline.py b/src/render/pipeline.py
index 86d8f50..1d3afb5 100644
--- a/src/render/pipeline.py
+++ b/src/render/pipeline.py
@@ -22,26 +22,26 @@ class RenderPipeline:
# Paint cache
self._text_paint: Optional[skia.Paint] = None
self._display_list: Optional[DisplayList] = None
-
+
# Base URL for resolving relative paths
self.base_url: Optional[str] = None
# Debug mode
self.debug_mode = False
-
+
# Async image loading
self.async_images = True # Enable async image loading by default
self._on_needs_redraw: Optional[Callable[[], None]] = None
-
+
def set_redraw_callback(self, callback: Callable[[], None]):
"""Set a callback to be called when async images finish loading."""
self._on_needs_redraw = callback
-
+
# Also set on ImageLayout class for global notification
def on_image_loaded():
if self._on_needs_redraw:
self._on_needs_redraw()
-
+
ImageLayout._on_any_image_loaded = on_image_loaded
def layout(self, document: Element, width: int) -> DocumentLayout:
@@ -60,7 +60,7 @@ class RenderPipeline:
# Build new layout with base_url for resolving image paths
self._layout = DocumentLayout(
- document,
+ document,
base_url=self.base_url,
async_images=self.async_images
)
@@ -110,7 +110,25 @@ class RenderPipeline:
continue
font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text)
- canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint)
+
+ # Use line color if specified (for links), otherwise black
+ paint = skia.Paint()
+ paint.setAntiAlias(True)
+ if line.color:
+ paint.setColor(self._parse_color(line.color))
+ else:
+ paint.setColor(skia.ColorBLACK)
+
+ canvas.drawString(line.text, line.x, baseline_y, font, paint)
+
+ # Draw underline for links
+ if line.href:
+ underline_paint = skia.Paint()
+ underline_paint.setColor(paint.getColor())
+ underline_paint.setStyle(skia.Paint.kStroke_Style)
+ underline_paint.setStrokeWidth(1)
+ underline_y = baseline_y + 2
+ canvas.drawLine(line.x, underline_y, line.x + line.width, underline_y, underline_paint)
# Render visible images (both loaded and placeholder)
for layout_image in layout.images:
@@ -122,12 +140,12 @@ class RenderPipeline:
# Use image_layout dimensions directly for accurate sizing after async load
img_width = image_layout.width if image_layout.width > 0 else layout_image.width
img_height = image_layout.height if image_layout.height > 0 else layout_image.height
-
+
# Always create DrawImage command - it handles None images as placeholders
draw_cmd = DrawImage(
- layout_image.x,
+ layout_image.x,
layout_image.y,
- img_width,
+ img_width,
img_height,
image_layout.image, # May be None, DrawImage handles this
image_layout.alt_text
@@ -188,8 +206,8 @@ class RenderPipeline:
def get_text_layout(self) -> list:
"""
- Get the text layout for text selection.
- Returns list of line info dicts with char_positions.
+ Get the text layout for text selection and link hit testing.
+ Returns list of line info dicts with char_positions and href.
"""
if self._layout is None:
return []
@@ -203,7 +221,8 @@ class RenderPipeline:
"width": line.width,
"height": line.height,
"font_size": line.font_size,
- "char_positions": line.char_positions
+ "char_positions": line.char_positions,
+ "href": getattr(line, "href", None)
})
return result
@@ -218,3 +237,63 @@ class RenderPipeline:
self._layout = None
self._layout_doc_id = None
self._display_list = None
+
+ def _parse_color(self, color_str: str) -> int:
+ """Parse a CSS color string to a Skia color value.
+
+ Supports:
+ - Hex colors: #rgb, #rrggbb
+ - Named colors (limited set)
+
+ Note: Very light colors (like white) that would be invisible on
+ our white background are converted to black.
+ """
+ if not color_str:
+ return skia.ColorBLACK
+
+ color_str = color_str.strip().lower()
+
+ # Named colors
+ named_colors = {
+ "black": skia.ColorBLACK,
+ "white": skia.ColorBLACK, # White is invisible on white bg, use black
+ "red": skia.ColorRED,
+ "green": skia.ColorGREEN,
+ "blue": skia.ColorBLUE,
+ "yellow": skia.ColorYELLOW,
+ "cyan": skia.ColorCYAN,
+ "magenta": skia.ColorMAGENTA,
+ "gray": skia.ColorGRAY,
+ "grey": skia.ColorGRAY,
+ }
+
+ if color_str in named_colors:
+ return named_colors[color_str]
+
+ # Hex colors
+ if color_str.startswith("#"):
+ hex_str = color_str[1:]
+ try:
+ if len(hex_str) == 3:
+ # #rgb -> #rrggbb
+ r = int(hex_str[0] * 2, 16)
+ g = int(hex_str[1] * 2, 16)
+ b = int(hex_str[2] * 2, 16)
+ elif len(hex_str) == 6:
+ r = int(hex_str[0:2], 16)
+ g = int(hex_str[2:4], 16)
+ b = int(hex_str[4:6], 16)
+ else:
+ return skia.ColorBLACK
+
+ # Check if color is too light (would be invisible on white)
+ # Use relative luminance approximation
+ if r > 240 and g > 240 and b > 240:
+ return skia.ColorBLACK
+
+ return skia.Color(r, g, b, 255)
+ except ValueError:
+ pass
+
+ # Fallback to black
+ return skia.ColorBLACK
diff --git a/tests/test_css.py b/tests/test_css.py
index 4d94b9f..d527d07 100644
--- a/tests/test_css.py
+++ b/tests/test_css.py
@@ -1,94 +1,93 @@
"""Tests for CSS parsing and style computation."""
-import pytest
from src.parser.css import (
- Selector, CSSRule, CSSParser, parse, parse_inline_style
+ Selector, parse, parse_inline_style
)
from src.parser.html import Element, Text
from src.parser.style import (
- ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES
+ ComputedStyle, StyleResolver
)
class TestSelector:
"""Test CSS selector parsing and matching."""
-
+
def test_tag_selector(self):
sel = Selector("p")
assert sel.tag == "p"
assert sel.id is None
assert sel.classes == []
-
+
def test_class_selector(self):
sel = Selector(".container")
assert sel.tag is None
assert sel.classes == ["container"]
-
+
def test_id_selector(self):
sel = Selector("#header")
assert sel.id == "header"
assert sel.tag is None
-
+
def test_compound_selector(self):
sel = Selector("div.container")
assert sel.tag == "div"
assert sel.classes == ["container"]
-
+
def test_complex_compound_selector(self):
sel = Selector("div#main.container.active")
assert sel.tag == "div"
assert sel.id == "main"
assert set(sel.classes) == {"container", "active"}
-
+
def test_specificity_tag_only(self):
sel = Selector("p")
assert sel.specificity() == (0, 0, 1)
-
+
def test_specificity_class_only(self):
sel = Selector(".container")
assert sel.specificity() == (0, 1, 0)
-
+
def test_specificity_id_only(self):
sel = Selector("#header")
assert sel.specificity() == (1, 0, 0)
-
+
def test_specificity_compound(self):
sel = Selector("div#main.container.active")
assert sel.specificity() == (1, 2, 1)
-
+
def test_matches_tag(self):
sel = Selector("p")
elem = Element("p")
assert sel.matches(elem) is True
-
+
elem2 = Element("div")
assert sel.matches(elem2) is False
-
+
def test_matches_class(self):
sel = Selector(".container")
elem = Element("div", {"class": "container sidebar"})
assert sel.matches(elem) is True
-
+
elem2 = Element("div", {"class": "sidebar"})
assert sel.matches(elem2) is False
-
+
def test_matches_id(self):
sel = Selector("#header")
elem = Element("div", {"id": "header"})
assert sel.matches(elem) is True
-
+
elem2 = Element("div", {"id": "footer"})
assert sel.matches(elem2) is False
-
+
def test_matches_compound(self):
sel = Selector("div.container")
elem = Element("div", {"class": "container"})
assert sel.matches(elem) is True
-
+
# Wrong tag
elem2 = Element("p", {"class": "container"})
assert sel.matches(elem2) is False
-
+
# Wrong class
elem3 = Element("div", {"class": "sidebar"})
assert sel.matches(elem3) is False
@@ -96,18 +95,18 @@ class TestSelector:
class TestCSSParser:
"""Test CSS stylesheet parsing."""
-
+
def test_empty_stylesheet(self):
rules = parse("")
assert rules == []
-
+
def test_single_rule(self):
css = "p { color: red; }"
rules = parse(css)
assert len(rules) == 1
assert rules[0].selector.tag == "p"
assert rules[0].declarations == {"color": "red"}
-
+
def test_multiple_rules(self):
css = """
p { color: red; }
@@ -117,7 +116,7 @@ class TestCSSParser:
assert len(rules) == 2
assert rules[0].selector.tag == "p"
assert rules[1].selector.tag == "div"
-
+
def test_multiple_declarations(self):
css = "p { color: red; font-size: 14px; margin: 10px; }"
rules = parse(css)
@@ -127,7 +126,7 @@ class TestCSSParser:
"font-size": "14px",
"margin": "10px"
}
-
+
def test_multiline_declarations(self):
css = """
p {
@@ -143,7 +142,7 @@ class TestCSSParser:
"font-size": "14px",
"margin": "10px"
}
-
+
def test_no_semicolon_on_last_declaration(self):
css = "p { color: red; font-size: 14px }"
rules = parse(css)
@@ -151,34 +150,34 @@ class TestCSSParser:
"color": "red",
"font-size": "14px"
}
-
+
def test_class_selector_rule(self):
css = ".container { width: 100%; }"
rules = parse(css)
assert len(rules) == 1
assert rules[0].selector.classes == ["container"]
assert rules[0].declarations == {"width": "100%"}
-
+
def test_id_selector_rule(self):
css = "#header { height: 50px; }"
rules = parse(css)
assert len(rules) == 1
assert rules[0].selector.id == "header"
assert rules[0].declarations == {"height": "50px"}
-
+
def test_compound_selector_rule(self):
css = "div.container { padding: 20px; }"
rules = parse(css)
assert len(rules) == 1
assert rules[0].selector.tag == "div"
assert rules[0].selector.classes == ["container"]
-
+
def test_whitespace_handling(self):
css = " p { color : red ; } "
rules = parse(css)
assert len(rules) == 1
assert rules[0].declarations == {"color": "red"}
-
+
def test_comments(self):
css = """
/* This is a comment */
@@ -190,38 +189,38 @@ class TestCSSParser:
assert len(rules) == 2
assert rules[0].selector.tag == "p"
assert rules[1].selector.tag == "div"
-
+
def test_property_values_with_spaces(self):
css = "p { font-family: Arial, sans-serif; }"
rules = parse(css)
assert rules[0].declarations == {"font-family": "Arial, sans-serif"}
-
+
def test_complex_stylesheet(self):
css = """
/* Reset */
* { margin: 0; padding: 0; }
-
+
body {
font-family: Arial, sans-serif;
font-size: 16px;
color: #333;
}
-
+
h1 {
font-size: 32px;
margin-bottom: 20px;
}
-
+
.container {
width: 960px;
margin: 0 auto;
}
-
+
#header {
background: #f0f0f0;
padding: 10px;
}
-
+
div.highlight {
background: yellow;
font-weight: bold;
@@ -229,7 +228,7 @@ class TestCSSParser:
"""
rules = parse(css)
assert len(rules) == 6
-
+
# Check body rule
body_rule = next(r for r in rules if r.selector.tag == "body")
assert "font-family" in body_rule.declarations
@@ -238,34 +237,34 @@ class TestCSSParser:
class TestInlineStyleParser:
"""Test inline style attribute parsing."""
-
+
def test_empty_style(self):
decls = parse_inline_style("")
assert decls == {}
-
+
def test_single_declaration(self):
decls = parse_inline_style("color: red")
assert decls == {"color": "red"}
-
+
def test_multiple_declarations(self):
decls = parse_inline_style("color: red; font-size: 14px")
assert decls == {"color": "red", "font-size": "14px"}
-
+
def test_trailing_semicolon(self):
decls = parse_inline_style("color: red; font-size: 14px;")
assert decls == {"color": "red", "font-size": "14px"}
-
+
def test_whitespace_handling(self):
decls = parse_inline_style(" color : red ; font-size : 14px ")
assert decls == {"color": "red", "font-size": "14px"}
-
+
def test_complex_values(self):
decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px")
assert decls == {
"font-family": "Arial, sans-serif",
"margin": "10px 20px"
}
-
+
def test_malformed_ignored(self):
# Missing colon
decls = parse_inline_style("color red; font-size: 14px")
@@ -274,36 +273,36 @@ class TestInlineStyleParser:
class TestComputedStyle:
"""Test computed style value accessors."""
-
+
def test_empty_style(self):
style = ComputedStyle()
assert style.get("color") == ""
assert style.get("color", "black") == "black"
-
+
def test_get_set(self):
style = ComputedStyle()
style.set("color", "red")
assert style.get("color") == "red"
-
+
def test_get_int(self):
style = ComputedStyle()
style.set("font-size", "16px")
assert style.get_int("font-size") == 16
-
+
def test_get_int_no_unit(self):
style = ComputedStyle()
style.set("font-size", "16")
assert style.get_int("font-size") == 16
-
+
def test_get_int_default(self):
style = ComputedStyle()
assert style.get_int("font-size", 14) == 14
-
+
def test_get_float(self):
style = ComputedStyle()
style.set("margin", "10.5px")
assert style.get_float("margin") == 10.5
-
+
def test_get_float_default(self):
style = ComputedStyle()
assert style.get_float("margin", 5.5) == 5.5
@@ -311,44 +310,44 @@ class TestComputedStyle:
class TestStyleResolver:
"""Test style resolution with cascade and inheritance."""
-
+
def test_default_styles(self):
resolver = StyleResolver()
elem = Element("p")
style = resolver.resolve_style(elem)
-
+
assert style.get("display") == "block"
assert style.get("margin-top") == "16px"
assert style.get("margin-bottom") == "16px"
-
+
def test_no_default_for_unknown_tag(self):
resolver = StyleResolver()
elem = Element("unknown")
style = resolver.resolve_style(elem)
-
+
# Should have empty properties (no defaults)
assert style.get("display") == ""
-
+
def test_stylesheet_overrides_default(self):
rules = parse("p { margin-top: 20px; }")
resolver = StyleResolver(rules)
elem = Element("p")
style = resolver.resolve_style(elem)
-
+
# Stylesheet should override default
assert style.get("margin-top") == "20px"
# But default not overridden should remain
assert style.get("margin-bottom") == "16px"
-
+
def test_inline_overrides_stylesheet(self):
rules = parse("p { color: blue; }")
resolver = StyleResolver(rules)
elem = Element("p", {"style": "color: red"})
style = resolver.resolve_style(elem)
-
+
# Inline should win
assert style.get("color") == "red"
-
+
def test_specificity_class_over_tag(self):
rules = parse("""
p { color: blue; }
@@ -357,10 +356,10 @@ class TestStyleResolver:
resolver = StyleResolver(rules)
elem = Element("p", {"class": "highlight"})
style = resolver.resolve_style(elem)
-
+
# Class selector has higher specificity
assert style.get("color") == "red"
-
+
def test_specificity_id_over_class(self):
rules = parse("""
p { color: blue; }
@@ -370,53 +369,53 @@ class TestStyleResolver:
resolver = StyleResolver(rules)
elem = Element("p", {"class": "highlight", "id": "main"})
style = resolver.resolve_style(elem)
-
+
# ID selector has highest specificity
assert style.get("color") == "green"
-
+
def test_inheritance_from_parent(self):
rules = parse("body { color: blue; font-size: 16px; }")
resolver = StyleResolver(rules)
-
+
parent = Element("body")
parent_style = resolver.resolve_style(parent)
-
+
child = Element("div")
child_style = resolver.resolve_style(child, parent_style)
-
+
# Should inherit color and font-size
assert child_style.get("color") == "blue"
assert child_style.get("font-size") == "16px"
-
+
def test_non_inherited_properties(self):
rules = parse("body { margin: 10px; }")
resolver = StyleResolver(rules)
-
+
parent = Element("body")
parent_style = resolver.resolve_style(parent)
-
+
child = Element("div")
child_style = resolver.resolve_style(child, parent_style)
-
+
# Margin should not inherit
assert child_style.get("margin") == ""
-
+
def test_child_overrides_inherited(self):
rules = parse("""
body { color: blue; }
p { color: red; }
""")
resolver = StyleResolver(rules)
-
+
parent = Element("body")
parent_style = resolver.resolve_style(parent)
-
+
child = Element("p")
child_style = resolver.resolve_style(child, parent_style)
-
+
# Child's own style should override inherited
assert child_style.get("color") == "red"
-
+
def test_resolve_tree(self):
css = """
body { color: blue; font-size: 16px; }
@@ -425,7 +424,7 @@ class TestStyleResolver:
"""
rules = parse(css)
resolver = StyleResolver(rules)
-
+
# Build tree
root = Element("body")
p1 = Element("p", parent=root)
@@ -433,46 +432,46 @@ class TestStyleResolver:
text = Text("Hello", parent=p1)
root.children = [p1, p2]
p1.children = [text]
-
+
# Resolve entire tree
resolver.resolve_tree(root)
-
+
# Check root
assert root.computed_style.get("color") == "blue"
assert root.computed_style.get("font-size") == "16px"
-
+
# Check p1 (inherits color)
assert p1.computed_style.get("color") == "blue"
assert p1.computed_style.get("margin") == "10px"
-
+
# Check p2 (inherits + has class)
assert p2.computed_style.get("color") == "blue"
assert p2.computed_style.get("background") == "yellow"
-
+
# Check text (has parent style)
assert text.computed_style.get("color") == "blue"
-
+
def test_heading_defaults(self):
resolver = StyleResolver()
-
+
h1 = Element("h1")
h1_style = resolver.resolve_style(h1)
assert h1_style.get("font-size") == "32px"
assert h1_style.get("font-weight") == "bold"
-
+
h2 = Element("h2")
h2_style = resolver.resolve_style(h2)
assert h2_style.get("font-size") == "24px"
-
+
def test_inline_elements(self):
resolver = StyleResolver()
-
+
a = Element("a")
a_style = resolver.resolve_style(a)
assert a_style.get("display") == "inline"
assert a_style.get("color") == "blue"
assert a_style.get("text-decoration") == "underline"
-
+
span = Element("span")
span_style = resolver.resolve_style(span)
assert span_style.get("display") == "inline"
diff --git a/tests/test_html_parsing.py b/tests/test_html_parsing.py
index d0392b3..dd625c9 100644
--- a/tests/test_html_parsing.py
+++ b/tests/test_html_parsing.py
@@ -60,7 +60,7 @@ class TestParseHTML:
if hasattr(child, "tag") and child.tag == "style":
style_elem = child
break
-
+
assert style_elem is not None
# Style content should be in the element
joined = " ".join(collect_text(style_elem))
diff --git a/tests/test_images.py b/tests/test_images.py
index d0ff902..ef9add2 100644
--- a/tests/test_images.py
+++ b/tests/test_images.py
@@ -1,13 +1,11 @@
"""Tests for image loading and rendering."""
-import pytest
import skia
-from src.network.images import load_image, ImageCache, _load_data_url
+from src.network.images import ImageCache, _load_data_url
from src.layout.embed import ImageLayout
from src.parser.html import Element, parse_html
from src.render.paint import DrawImage
from src.layout.document import DocumentLayout, LayoutImage
-from io import BytesIO
def create_test_image(width=100, height=100):
@@ -21,47 +19,47 @@ def create_test_image(width=100, height=100):
class TestImageCache:
"""Test image caching."""
-
+
def test_cache_singleton(self):
"""ImageCache should be a singleton."""
cache1 = ImageCache()
cache2 = ImageCache()
assert cache1 is cache2
-
+
def test_cache_get_set(self):
"""Test basic cache operations."""
cache = ImageCache()
cache.clear()
-
+
# Create a simple test image
image = create_test_image(100, 100)
-
+
# Initially empty
assert cache.get("test_url") is None
-
+
# Set and get
cache.set("test_url", image)
cached = cache.get("test_url")
assert cached is not None
assert cached.width() == 100
assert cached.height() == 100
-
+
def test_cache_clear(self):
"""Test cache clearing."""
cache = ImageCache()
cache.clear()
-
+
image = create_test_image(100, 100)
cache.set("test_url", image)
assert cache.get("test_url") is not None
-
+
cache.clear()
assert cache.get("test_url") is None
class TestDataURLLoading:
"""Test data URL image loading."""
-
+
def test_load_base64_png(self):
"""Test loading a base64-encoded PNG data URL."""
# Simple 1x1 red PNG
@@ -69,29 +67,29 @@ class TestDataURLLoading:
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
)
-
+
image = _load_data_url(data_url)
assert image is not None
assert image.width() == 1
assert image.height() == 1
-
+
def test_load_invalid_data_url(self):
"""Test loading an invalid data URL."""
image = _load_data_url("data:invalid")
assert image is None
-
+
image = _load_data_url("not_a_data_url")
assert image is None
class TestImageLayout:
"""Test ImageLayout class."""
-
+
def test_image_layout_init(self):
"""Test ImageLayout initialization."""
node = Element("img", {"src": "test.png"})
layout = ImageLayout(node)
-
+
assert layout.node == node
assert layout.x == 0
assert layout.y == 0
@@ -99,109 +97,109 @@ class TestImageLayout:
assert layout.height == 0
assert layout.image is None
assert layout.is_inline is True
-
+
def test_layout_with_intrinsic_size(self):
"""Test layout calculation with intrinsic image size."""
node = Element("img", {"src": "test.png"})
layout = ImageLayout(node)
-
+
# Create a test image
layout.image = create_test_image(200, 150)
-
+
width = layout.layout()
-
+
assert layout.width == 200
assert layout.height == 150
assert width == 200
-
+
def test_layout_with_explicit_width(self):
"""Test layout with explicit width attribute."""
node = Element("img", {"src": "test.png", "width": "100"})
layout = ImageLayout(node)
-
+
# Create a test image (200x150)
layout.image = create_test_image(200, 150)
-
+
layout.layout()
-
+
# Should maintain aspect ratio
assert layout.width == 100
assert layout.height == 75 # 100 * (150/200)
-
+
def test_layout_with_explicit_height(self):
"""Test layout with explicit height attribute."""
node = Element("img", {"src": "test.png", "height": "100"})
layout = ImageLayout(node)
-
+
# Create a test image (200x150)
layout.image = create_test_image(200, 150)
-
+
layout.layout()
-
+
# Should maintain aspect ratio
assert layout.height == 100
assert abs(layout.width - 133.33) < 1 # 100 * (200/150)
-
+
def test_layout_with_both_dimensions(self):
"""Test layout with both width and height specified."""
node = Element("img", {"src": "test.png", "width": "100", "height": "50"})
layout = ImageLayout(node)
-
+
# Create a test image
layout.image = create_test_image(200, 150)
-
+
layout.layout()
-
+
# Should use explicit dimensions (no aspect ratio preservation)
assert layout.width == 100
assert layout.height == 50
-
+
def test_layout_with_max_width(self):
"""Test layout constrained by max_width."""
node = Element("img", {"src": "test.png"})
layout = ImageLayout(node)
-
+
# Create a large test image
layout.image = create_test_image(1000, 500)
-
+
layout.layout(max_width=400)
-
+
# Should constrain to max_width and maintain aspect ratio
assert layout.width == 400
assert layout.height == 200 # 400 * (500/1000)
-
+
def test_layout_no_image(self):
"""Test layout when image fails to load."""
node = Element("img", {"src": "test.png", "alt": "Test image"})
layout = ImageLayout(node)
-
+
# Don't set an image (simulating load failure)
layout.alt_text = "Test image"
layout.layout()
-
+
# Should use placeholder dimensions
assert layout.width == 100
assert layout.height == 100
-
+
def test_alt_text_extraction(self):
"""Test alt text extraction."""
node = Element("img", {"src": "test.png", "alt": "Description"})
layout = ImageLayout(node)
-
+
layout.load()
-
+
assert layout.alt_text == "Description"
class TestDrawImage:
"""Test DrawImage paint command."""
-
+
def test_draw_image_init(self):
"""Test DrawImage initialization."""
image = create_test_image(100, 100)
-
+
cmd = DrawImage(10, 20, 100, 100, image, "Test")
-
+
assert cmd.x == 10
assert cmd.y == 20
assert cmd.width == 100
@@ -209,75 +207,75 @@ class TestDrawImage:
assert cmd.image is image
assert cmd.alt_text == "Test"
assert cmd.rect == (10, 20, 110, 120)
-
+
def test_draw_image_with_valid_image(self):
"""Test drawing a valid image."""
image = create_test_image(100, 100)
-
+
# Create a surface to draw on
surface = skia.Surface(200, 200)
canvas = surface.getCanvas()
-
+
cmd = DrawImage(10, 20, 100, 100, image)
cmd.execute(canvas)
-
+
# If it doesn't throw, it worked
assert True
-
+
def test_draw_image_with_null_image(self):
"""Test drawing when image is None (placeholder)."""
# Create a surface to draw on
surface = skia.Surface(200, 200)
canvas = surface.getCanvas()
-
+
cmd = DrawImage(10, 20, 100, 100, None, "Failed to load")
cmd.execute(canvas)
-
+
# Should draw placeholder without error
assert True
class TestDocumentLayoutImages:
"""Test image integration in DocumentLayout."""
-
+
def test_parse_img_element(self):
"""Test that img elements are parsed correctly."""
html = '
'
root = parse_html(html)
-
+
# Find the img element
body = root.children[0]
img = body.children[0]
-
+
assert img.tag == "img"
assert img.attributes["src"] == "test.png"
assert img.attributes["alt"] == "Test image"
assert img.attributes["width"] == "100"
-
+
def test_layout_with_image(self):
"""Test document layout with an image."""
html = '
Text before

Text after
' root = parse_html(html) - + layout = DocumentLayout(root) - + # Mock the image loading by creating the images manually # This would normally happen in _collect_blocks # For now, just verify the structure is created lines = layout.layout(800) - + # Should have lines and potentially images assert isinstance(lines, list) - + def test_layout_image_class(self): """Test LayoutImage class.""" node = Element("img", {"src": "test.png"}) image_layout = ImageLayout(node) image_layout.image = create_test_image(100, 100) image_layout.layout() - + layout_image = LayoutImage(image_layout, 10, 20) - + assert layout_image.x == 10 assert layout_image.y == 20 assert layout_image.width == 100 @@ -287,7 +285,7 @@ class TestDocumentLayoutImages: class TestImageIntegration: """Integration tests for the complete image pipeline.""" - + def test_html_with_data_url_image(self): """Test parsing and layout of HTML with data URL image.""" # 1x1 red PNG @@ -295,10 +293,10 @@ class TestImageIntegration: "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ) - + html = f'Before
After
' root = parse_html(html) - + # Verify structure body = root.children[0] # The img tag is self-closing, so the second p tag becomes a child of img @@ -306,7 +304,7 @@ class TestImageIntegration: assert len(body.children) >= 2 assert body.children[0].tag == "p" assert body.children[1].tag == "img" - + def test_nested_image_in_paragraph(self): """Test that images inside paragraphs are collected.""" # 1x1 red PNG @@ -314,28 +312,28 @@ class TestImageIntegration: "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" ) - + html = f'Text before text after
'
root = parse_html(html)
-
+
layout = DocumentLayout(root)
layout.layout(800)
-
+
# Should have image layout even though load failed
assert len(layout.images) >= 1
-
+
# Check alt text is set
if layout.images:
img = layout.images[0]
@@ -344,41 +342,41 @@ class TestImageIntegration:
class TestURLResolution:
"""Test URL resolution for images."""
-
+
def test_resolve_about_page_relative_url(self):
"""Test resolving relative URLs for about: pages."""
from src.network.images import _resolve_url, ASSETS_DIR
-
+
# Relative URL from about:startpage should resolve to assets directory
resolved = _resolve_url("../WebBowserLogo.jpeg", "about:startpage")
-
+
# Should be an absolute path to the assets directory
assert "WebBowserLogo.jpeg" in resolved
assert str(ASSETS_DIR) in resolved or resolved.endswith("WebBowserLogo.jpeg")
-
+
def test_resolve_http_relative_url(self):
"""Test resolving relative URLs for HTTP pages."""
from src.network.images import _resolve_url
-
+
# Relative URL from HTTP page
resolved = _resolve_url("images/photo.jpg", "https://example.com/page/index.html")
-
+
assert resolved == "https://example.com/page/images/photo.jpg"
-
+
def test_resolve_absolute_url(self):
"""Test that absolute URLs are returned unchanged."""
from src.network.images import _resolve_url
-
+
url = "https://example.com/image.png"
resolved = _resolve_url(url, "https://other.com/page.html")
-
+
assert resolved == url
-
+
def test_resolve_data_url(self):
"""Test that data URLs are returned unchanged."""
from src.network.images import _resolve_url
-
+
url = "data:image/png;base64,abc123"
resolved = _resolve_url(url, "https://example.com/")
-
+
assert resolved == url
diff --git a/tests/test_links.py b/tests/test_links.py
new file mode 100644
index 0000000..013cd20
--- /dev/null
+++ b/tests/test_links.py
@@ -0,0 +1,341 @@
+"""Tests for link parsing, rendering, and navigation."""
+
+import pytest
+
+from src.parser.html import parse_html, parse_html_with_styles, Element, Text
+from src.layout.document import DocumentLayout, LayoutLine
+from src.network.url import URL
+
+
+class TestLinkParsing:
+ """Tests for parsing anchor elements from HTML."""
+
+ def test_parse_simple_link(self):
+ """Test parsing a simple anchor tag."""
+ html = "Click here"
+ root = parse_html(html)
+
+ # Find the anchor element
+ body = root.children[0]
+ assert body.tag == "body"
+ anchor = body.children[0]
+ assert anchor.tag == "a"
+ assert anchor.attributes.get("href") == "https://example.com"
+
+ def test_parse_link_with_text(self):
+ """Test that link text content is preserved."""
+ html = "Link Text"
+ root = parse_html(html)
+
+ body = root.children[0]
+ anchor = body.children[0]
+ assert len(anchor.children) == 1
+ assert isinstance(anchor.children[0], Text)
+ assert anchor.children[0].text.strip() == "Link Text"
+
+ def test_parse_link_in_paragraph(self):
+ """Test parsing a link inside a paragraph."""
+ html = "Visit our site today!
" + root = parse_html(html) + + body = root.children[0] + # The parser may flatten this - check for anchor presence + found_anchor = False + + def find_anchor(node): + nonlocal found_anchor + if isinstance(node, Element) and node.tag == "a": + found_anchor = True + assert node.attributes.get("href") == "https://test.com" + if hasattr(node, "children"): + for child in node.children: + find_anchor(child) + + find_anchor(body) + assert found_anchor, "Anchor element not found" + + def test_parse_link_with_relative_href(self): + """Test parsing a link with a relative URL.""" + html = "About" + root = parse_html(html) + + body = root.children[0] + anchor = body.children[0] + assert anchor.attributes.get("href") == "/about" + + def test_parse_link_with_anchor_href(self): + """Test parsing a link with an anchor reference.""" + html = "Jump" + root = parse_html(html) + + body = root.children[0] + anchor = body.children[0] + assert anchor.attributes.get("href") == "#section" + + +class TestLinkLayout: + """Tests for link layout and styling.""" + + def test_link_layout_has_href(self): + """Test that layout lines for links include href.""" + html = "Link" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # Find line with href + link_lines = [line for line in layout.lines if line.href] + assert len(link_lines) > 0, "No link lines found" + assert link_lines[0].href == "https://example.com" + + def test_link_layout_has_color(self): + """Test that layout lines for links have a color.""" + html = "Link" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # Find line with color + link_lines = [line for line in layout.lines if line.href] + assert len(link_lines) > 0 + # Should have either CSS-specified color or default link color + assert link_lines[0].color is not None + + def test_non_link_has_no_href(self): + """Test that non-link elements don't have href.""" + html = "Regular paragraph
" + root = parse_html_with_styles(html) + + layout = DocumentLayout(root) + layout.layout(800) + + # All lines should have no href + for line in layout.lines: + assert line.href is None + + def test_layout_line_constructor(self): + """Test LayoutLine constructor with color and href.""" + line = LayoutLine( + text="Click me", + x=10, + y=20, + font_size=14, + color="#0066cc", + href="https://example.com" + ) + + assert line.text == "Click me" + assert line.color == "#0066cc" + assert line.href == "https://example.com" + + def test_layout_line_default_values(self): + """Test LayoutLine defaults for color and href.""" + line = LayoutLine( + text="Normal text", + x=10, + y=20, + font_size=14 + ) + + assert line.color is None + assert line.href is None + + +class TestLinkURLResolution: + """Tests for URL resolution of links.""" + + def test_resolve_absolute_url(self): + """Test that absolute URLs are preserved.""" + base = URL("https://example.com/page") + resolved = base.resolve("https://other.com/path") + assert str(resolved) == "https://other.com/path" + + def test_resolve_relative_url(self): + """Test resolving a relative URL.""" + base = URL("https://example.com/page") + resolved = base.resolve("/about") + assert str(resolved) == "https://example.com/about" + + def test_resolve_relative_path(self): + """Test resolving a relative path.""" + base = URL("https://example.com/dir/page") + resolved = base.resolve("other") + assert str(resolved) == "https://example.com/dir/other" + + def test_resolve_parent_relative(self): + """Test resolving a parent-relative path.""" + base = URL("https://example.com/dir/subdir/page") + resolved = base.resolve("../other") + assert str(resolved) == "https://example.com/dir/other" + + def test_resolve_anchor_only(self): + """Test resolving an anchor-only URL.""" + base = URL("https://example.com/page") + resolved = base.resolve("#section") + assert str(resolved) == "https://example.com/page#section" + + def test_resolve_query_string(self): + """Test resolving a URL with query string.""" + base = URL("https://example.com/page") + resolved = base.resolve("?query=value") + assert str(resolved) == "https://example.com/page?query=value" + + +class TestRenderPipelineColorParsing: + """Tests for color parsing in the render pipeline. + + Note: These tests only run when skia is NOT mocked (i.e., when run in isolation). + When run after test_render.py, skia becomes a MagicMock and these tests are skipped. + """ + + def test_parse_hex_color_6digit(self): + """Test parsing 6-digit hex colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.Color, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + color = pipeline._parse_color("#0066cc") + + # Extract RGB components (Skia color is ARGB) + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + + assert r == 0x00 + assert g == 0x66 + assert b == 0xcc + + def test_parse_hex_color_3digit(self): + """Test parsing 3-digit hex colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.Color, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + color = pipeline._parse_color("#abc") + + # #abc should expand to #aabbcc + r = (color >> 16) & 0xFF + g = (color >> 8) & 0xFF + b = color & 0xFF + + # Each digit is doubled: a->aa, b->bb, c->cc + # But our implementation uses int("a" * 2, 16) which is int("aa", 16) = 170 + assert r == 0xaa + assert g == 0xbb + assert b == 0xcc + + def test_parse_named_color(self): + """Test parsing named colors.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.ColorBLACK, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + + # Test that named colors return a valid integer color value + black = pipeline._parse_color("black") + white = pipeline._parse_color("white") + red = pipeline._parse_color("red") + + # Black should be 0xFF000000 (opaque black in ARGB) + assert isinstance(black, int) + assert (black & 0xFFFFFF) == 0x000000 # RGB is 0 + + # White is converted to black because it would be invisible on white bg + assert isinstance(white, int) + assert (white & 0xFFFFFF) == 0x000000 # Converted to black + + # Red should have R=255, G=0, B=0 + assert isinstance(red, int) + r = (red >> 16) & 0xFF + assert r == 0xFF # Red component should be 255 + + def test_parse_invalid_color_returns_black(self): + """Test that invalid colors return black.""" + from src.render.pipeline import RenderPipeline + import skia + + # Skip if skia is mocked + if hasattr(skia.ColorBLACK, '_mock_name'): + pytest.skip("skia is mocked") + + pipeline = RenderPipeline() + + invalid = pipeline._parse_color("invalid") + invalid2 = pipeline._parse_color("#xyz") + invalid3 = pipeline._parse_color("") + + # All should return black (integer value) + assert isinstance(invalid, int) + assert isinstance(invalid2, int) + assert isinstance(invalid3, int) + + # RGB components should be 0 (black) + assert (invalid & 0xFFFFFF) == 0x000000 + + +class TestGetTextLayoutWithHref: + """Tests for text layout including href information.""" + + def test_get_text_layout_includes_href(self): + """Test that get_text_layout includes href for links.""" + from src.render.pipeline import RenderPipeline + + html = "Click" + root = parse_html_with_styles(html) + + pipeline = RenderPipeline() + pipeline.layout(root, 800) + + text_layout = pipeline.get_text_layout() + + # Find the link line + link_entries = [entry for entry in text_layout if entry.get("href")] + assert len(link_entries) > 0 + assert link_entries[0]["href"] == "https://example.com" + + def test_get_text_layout_normal_text_no_href(self): + """Test that normal text has no href in layout.""" + from src.render.pipeline import RenderPipeline + + html = "Normal text
" + root = parse_html_with_styles(html) + + pipeline = RenderPipeline() + pipeline.layout(root, 800) + + text_layout = pipeline.get_text_layout() + + # All entries should have href=None + for entry in text_layout: + assert entry.get("href") is None + + +class TestLinkDefaultStyling: + """Tests for default link styling from CSS.""" + + def test_link_default_color_in_css(self): + """Test that default.css defines link color.""" + from pathlib import Path + + css_path = Path(__file__).parent.parent / "assets" / "default.css" + assert css_path.exists(), "default.css should exist" + + css_content = css_path.read_text() + + # Check that 'a' selector is defined with a color + assert "a {" in css_content or "a{" in css_content.replace(" ", "") + assert "color:" in css_content diff --git a/tests/test_styling_integration.py b/tests/test_styling_integration.py index 1bf2231..a6aa051 100644 --- a/tests/test_styling_integration.py +++ b/tests/test_styling_integration.py @@ -1,13 +1,13 @@ """Integration tests for CSS styling system.""" import pytest -from src.parser.html import parse_html_with_styles, Element +from src.parser.html import parse_html_with_styles from src.layout.document import DocumentLayout class TestStyleIntegration: """Test end-to-end CSS parsing and layout integration.""" - + def test_parse_with_style_tag(self): html = """ @@ -22,7 +22,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element p_elem = None for child in root.children: @@ -31,12 +31,12 @@ class TestStyleIntegration: if hasattr(grandchild, "tag") and grandchild.tag == "p": p_elem = grandchild break - + assert p_elem is not None assert hasattr(p_elem, "computed_style") assert p_elem.computed_style.get("color") == "red" assert p_elem.computed_style.get("font-size") == "18px" - + def test_inline_style_override(self): html = """ @@ -46,7 +46,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -56,9 +56,9 @@ class TestStyleIntegration: assert p_elem.computed_style.get("color") == "blue" assert p_elem.computed_style.get("font-size") == "20px" return - + pytest.fail("P element not found") - + def test_cascade_priority(self): html = """ @@ -78,24 +78,24 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find body body = None for child in root.children: if hasattr(child, "tag") and child.tag == "body": body = child break - + assert body is not None paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"] assert len(paragraphs) == 4 - + # Check cascade assert paragraphs[0].computed_style.get("color") == "red" # Tag only assert paragraphs[1].computed_style.get("color") == "green" # Class wins assert paragraphs[2].computed_style.get("color") == "blue" # ID wins assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins - + def test_inheritance(self): html = """ @@ -112,7 +112,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the nested p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -150,10 +150,10 @@ class TestStyleIntegration: lines = layout.layout(800) # H1 should use custom font size assert lines[0].font_size == 40 - + # P should use custom font size assert lines[1].font_size == 20 - + def test_multiple_classes(self): html = """ @@ -169,7 +169,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -179,9 +179,9 @@ class TestStyleIntegration: assert grandchild.computed_style.get("font-size") == "24px" assert grandchild.computed_style.get("color") == "red" return - + pytest.fail("P element not found") - + def test_default_styles_applied(self): html = """ @@ -193,20 +193,20 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html) - + # Find elements body = None for child in root.children: if hasattr(child, "tag") and child.tag == "body": body = child break - + assert body is not None - + h1 = next((c for c in body.children if hasattr(c, "tag") and c.tag == "h1"), None) p = next((c for c in body.children if hasattr(c, "tag") and c.tag == "p"), None) a = next((c for c in body.children if hasattr(c, "tag") and c.tag == "a"), None) - + # Check default styles from default.css assert h1 is not None # Font-size from default.css is 2.5rem @@ -220,7 +220,7 @@ class TestStyleIntegration: # Link color from default.css assert a.computed_style.get("color") == "#0066cc" assert a.computed_style.get("text-decoration") == "none" - + def test_no_styles_when_disabled(self): html = """ @@ -235,7 +235,7 @@ class TestStyleIntegration: """ root = parse_html_with_styles(html, apply_styles=False) - + # Find the p element for child in root.children: if hasattr(child, "tag") and child.tag == "body": @@ -244,5 +244,5 @@ class TestStyleIntegration: # Should not have computed_style when disabled assert not hasattr(grandchild, "computed_style") return - + pytest.fail("P element not found") diff --git a/tests/test_tasks.py b/tests/test_tasks.py index 3f07b69..daa78dd 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,190 +1,189 @@ """Tests for the async task queue system.""" -import pytest import time import threading -from unittest.mock import Mock, patch +from unittest.mock import patch class TestTaskQueue: """Tests for the TaskQueue class.""" - + def test_task_queue_singleton(self): """Test that TaskQueue is a singleton.""" from src.network.tasks import TaskQueue - + # Reset singleton for clean test TaskQueue.reset_instance() - + q1 = TaskQueue() q2 = TaskQueue() - + assert q1 is q2 - + # Clean up TaskQueue.reset_instance() - + def test_submit_task_returns_id(self): """Test that submit returns a task ID.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + # Mock GLib.idle_add to avoid GTK dependency with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + task_id = queue.submit(lambda: 42) - + # Task ID should be non-negative (or -1 for cached) assert isinstance(task_id, int) - + # Wait for task to complete time.sleep(0.1) TaskQueue.reset_instance() - + def test_task_executes_function(self): """Test that submitted tasks are executed.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + result = [] - event = threading.Event() - + threading.Event() + def task(): result.append("executed") return "done" - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(task) - + # Wait for task to complete time.sleep(0.2) - + assert "executed" in result - + TaskQueue.reset_instance() - + def test_on_complete_callback(self): """Test that on_complete callback is called with result.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + results = [] - + def task(): return 42 - + def on_complete(result): results.append(result) - + with patch('src.network.tasks.GLib') as mock_glib: # Make idle_add execute immediately mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(task, on_complete=on_complete) - + # Wait for task to complete (may need more time under load) for _ in range(10): if 42 in results: break time.sleep(0.05) - + assert 42 in results - + TaskQueue.reset_instance() - + def test_on_error_callback(self): """Test that on_error callback is called on exception.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + errors = [] - + def failing_task(): raise ValueError("Test error") - + def on_error(e): errors.append(str(e)) - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + queue.submit(failing_task, on_error=on_error) - + # Wait for task to complete (may need more time under load) for _ in range(10): if len(errors) == 1: break time.sleep(0.05) - + assert len(errors) == 1 assert "Test error" in errors[0] - + TaskQueue.reset_instance() - + def test_cancel_task(self): """Test task cancellation.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + result = [] - + def slow_task(): time.sleep(1) result.append("completed") return True - + with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + task_id = queue.submit(slow_task) - + # Cancel immediately cancelled = queue.cancel(task_id) - + # May or may not be cancellable depending on timing assert isinstance(cancelled, bool) - + # Wait briefly time.sleep(0.1) - + TaskQueue.reset_instance() - + def test_pending_count(self): """Test pending task count.""" from src.network.tasks import TaskQueue - + TaskQueue.reset_instance() queue = TaskQueue() - + initial_count = queue.pending_count assert initial_count >= 0 - + TaskQueue.reset_instance() class TestAsyncImageLoading: """Tests for async image loading.""" - + def test_load_image_async_cached(self): """Test that cached images return -1 (no task needed).""" from src.network.images import load_image_async, load_image, ImageCache - + # Clear cache ImageCache().clear() - + # Load an image synchronously first (to cache it) data_url = ( "data:image/png;base64," @@ -192,43 +191,43 @@ class TestAsyncImageLoading: ) image = load_image(data_url) assert image is not None - + # Now load async - should hit cache and return -1 (no task) # We don't need a callback for this test - just checking return value task_id = load_image_async(data_url, on_complete=None) - + # Cached loads return -1 (no task created) assert task_id == -1 - + def test_load_image_async_uncached(self): """Test that uncached images create tasks.""" from src.network.images import load_image_async, ImageCache from src.network.tasks import TaskQueue - + # Clear cache ImageCache().clear() TaskQueue.reset_instance() - + # Use a data URL that's not cached data_url = ( "data:image/png;base64," "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVR42mP8z8DwHwYAAQYBA/5h2aw4AAAAAElFTkSuQmCC" ) - + # Patch GLib.idle_add to call callbacks immediately (no GTK main loop in tests) with patch('src.network.tasks.GLib') as mock_glib: mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() - + # Without a callback, it just submits the task task_id = load_image_async(data_url, on_complete=None) - + # Should create a task (non-negative ID) assert task_id >= 0 - + # Wait for task to complete time.sleep(0.3) - + # Image should now be cached assert ImageCache().has(data_url) - + TaskQueue.reset_instance()