From c9ef5e5c44c17a6f12d683c83dea77b33280a697 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Mon, 12 Jan 2026 10:22:34 +0100 Subject: [PATCH] Refactor test files to remove unnecessary imports and improve readability - Removed unused imports from various test files to streamline code. - Cleaned up whitespace in test cases for better consistency. - Updated dependency management in `uv.lock` to reflect changes in optional dependencies. - Ensured all tests maintain functionality while enhancing clarity and organization. --- .github/skills/skills.md | 345 ++++++++++++++++++++++++++++++++ main.py | 4 +- pyproject.toml | 8 +- src/browser/browser.py | 1 + src/browser/chrome.py | 315 +++++++++++++++-------------- src/browser/tab.py | 19 +- src/debug/dom_graph.py | 68 +++---- src/layout/block.py | 24 +-- src/layout/document.py | 61 +++--- src/layout/inline.py | 20 +- src/network/http.py | 41 ++-- src/render/fonts.py | 12 +- src/render/paint.py | 22 +- src/render/pipeline.py | 68 +++---- src/templates.py | 51 ++--- tests/test_browser.py | 89 ++++---- tests/test_cookies.py | 17 +- tests/test_dom_graph.py | 67 +++---- tests/test_dom_graph_page.py | 22 +- tests/test_frame.py | 33 ++- tests/test_html_parsing.py | 29 ++- tests/test_http.py | 66 +++--- tests/test_layout.py | 88 ++++---- tests/test_parser.py | 13 +- tests/test_render.py | 51 ++--- tests/test_templates.py | 27 ++- tests/test_url.py | 15 +- tests/test_url_normalization.py | 21 +- uv.lock | 17 +- 29 files changed, 989 insertions(+), 625 deletions(-) create mode 100644 .github/skills/skills.md diff --git a/.github/skills/skills.md b/.github/skills/skills.md new file mode 100644 index 0000000..0201185 --- /dev/null +++ b/.github/skills/skills.md @@ -0,0 +1,345 @@ +# Skills Index + +This file defines **canonical project skills** the LLM must follow. Each skill specifies the *one correct command* for a common project action. + +1. **Run the Project** → `uv run bowser` +2. **Test the Project** → `uv run pytest` +3. **Lint the Project** → `uv run ruff` + +Deviating from these commands is considered incorrect behavior unless explicitly instructed. + +--- + +# Skill: Run the Project with `uv run bowser` + +## Purpose +Teach the LLM **how and when to run this project** using the canonical command: + +```bash +uv run bowser +``` + +This skill ensures the LLM consistently uses the correct entry point, avoids ad‑hoc commands, and follows project conventions. + +--- + +## Canonical Command + +**Always run the project using:** + +```bash +uv run bowser +``` + +Do **not**: +- Call `python` directly +- Run scripts via file paths +- Use alternative task runners (e.g. `make`, `poetry run`, `pipenv run`) + +`uv` is the authoritative environment and dependency manager for this project, and `bowser` is the defined runtime entry point. + +--- + +## What `uv run bowser` Means + +- `uv run`: + - Ensures dependencies are resolved and installed according to the project configuration + - Executes commands inside the correct, isolated environment + +- `bowser`: + - The project’s primary executable / CLI + - Encapsulates startup logic, configuration loading, and runtime behavior + +Together, they guarantee a reproducible and correct execution environment. + +--- + +## When to Use This Command + +Use `uv run bowser` whenever you need to: + +- Start the application +- Run the main service or agent +- Execute project logic end‑to‑end +- Validate runtime behavior +- Demonstrate how the project is launched + +If the task says **“run the project”**, **“start the app”**, or **“execute Bowser”**, this is the command. + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run bowser` when: + +- Running tests (use the project’s test command instead) +- Running one‑off scripts unless explicitly routed through `bowser` +- Installing dependencies +- Linting or formatting code + +If unsure, default to **not running anything** and explain what would be executed. + +--- + +## How to Explain This to Humans + +When documenting or instructing users, say: + +> “Run the project with `uv run bowser`.” + +Optionally add: + +> “This ensures the correct environment and entry point are used.” + +Do **not** over‑explain unless the user asks. + +--- + +## Error Handling Guidance + +If `uv run bowser` fails: + +1. Assume a dependency or configuration issue +2. Report the error output verbatim +3. Do **not** substitute another execution method +4. Suggest fixing the root cause, not changing the command + +--- + +## Mental Model for the LLM + +- There is **one** correct way to run the project +- That way is **stable and intentional** +- Deviating from it is a bug + +Think of `uv run bowser` as: + +> “The project’s on‑switch.” + +--- + +## Summary (Checklist) + +Before suggesting how to run the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `bowser` +- [ ] You are not calling Python directly +- [ ] You are not inventing alternate commands + +If all are true, you are doing it right. + + +--- + +# Skill: Test the Project with `uv run pytest` + +## Purpose +Teach the LLM **how and when to run tests** for this project using the canonical command: + +```bash +uv run pytest +``` + +This skill ensures tests are executed in the correct environment, using the project’s standard tooling, without inventing alternate commands. + +--- + +## Canonical Command + +**Always run tests using:** + +```bash +uv run pytest +``` + +Do **not**: +- Call `pytest` directly +- Use `python -m pytest` +- Run tests via ad-hoc scripts or task runners + +`uv` is the authoritative environment manager, and `pytest` is the test runner for this project. + +--- + +## What `uv run pytest` Means + +- `uv run`: + - Ensures dependencies (including test dependencies) are resolved correctly + - Runs inside the same environment model as the application + +- `pytest`: + - Discovers and runs the project’s test suite + - Applies project-level configuration (e.g. `pytest.ini`, `pyproject.toml`) + +Together, they guarantee consistent and reproducible test execution. + +--- + +## When to Use This Command + +Use `uv run pytest` whenever you need to: + +- Run the full test suite +- Verify a change before or after modifying code +- Reproduce a failing test +- Validate behavior without starting the application + +If the task says **“run tests”**, **“test the project”**, or **“verify with pytest”**, this is the command. + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run pytest` when: + +- Running the application (use `uv run bowser`) +- Installing dependencies +- Linting or formatting code +- Executing non-test scripts + +If unsure, default to explaining what tests would be run rather than executing them. + +--- + +## Error Handling Guidance + +If `uv run pytest` fails: + +1. Capture and report the full pytest output +2. Identify whether the failure is: + - A test assertion failure + - A missing dependency or import error + - A configuration issue +3. Do **not** change the command to work around the failure +4. Fix the underlying cause, then re-run the same command + +--- + +## Mental Model for the LLM + +- There is **one** correct way to run tests +- Test execution should mirror the real runtime environment +- Consistency matters more than convenience + +Think of `uv run pytest` as: + +> “The project’s truth-check.” + +--- + +## Summary (Checklist) + +Before suggesting how to test the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `pytest` +- [ ] You are not calling Python directly +- [ ] You are not inventing alternate test commands + +If all are true, you are doing it right. + + +--- + +# Skill: Lint the Project with `uv run ruff` + +## Purpose +Teach the LLM **how and when to lint the project** using the canonical command: + +```bash +uv run ruff +``` + +This skill ensures linting is performed consistently, using the project’s configured rules and environment. + +--- + +## Canonical Command + +**Always lint the project using:** + +```bash +uv run ruff +``` + +Do **not**: +- Call `ruff` directly +- Use alternative linters unless explicitly instructed +- Invoke formatting or linting via ad-hoc scripts + +`uv` guarantees the correct environment, and `ruff` enforces the project’s linting standards. + +--- + +## What `uv run ruff` Means + +- `uv run`: + - Executes linting inside the managed project environment + - Ensures the correct version of `ruff` and dependencies are used + +- `ruff`: + - Performs fast, opinionated linting + - Applies rules configured in project files (e.g. `pyproject.toml`) + +Together, they provide deterministic and repeatable lint results. + +--- + +## When to Use This Command + +Use `uv run ruff` whenever you need to: + +- Check code quality +- Identify linting or style issues +- Validate changes before committing +- Respond to a request to “lint the project” or “run ruff” + +--- + +## When *Not* to Use This Command + +Do **not** use `uv run ruff` when: + +- Running the application (`uv run bowser`) +- Running tests (`uv run pytest`) +- Formatting code unless `ruff` is explicitly configured to do so +- Installing dependencies + +If unsure, explain what linting would check instead of executing it. + +--- + +## Error Handling Guidance + +If `uv run ruff` reports issues: + +1. Treat findings as authoritative +2. Report errors or warnings clearly +3. Do **not** suppress or bypass lint rules +4. Fix the code, then re-run the same command + +--- + +## Mental Model for the LLM + +- Linting enforces shared standards +- Speed and consistency matter more than flexibility +- There is **one** correct linting command + +Think of `uv run ruff` as: + +> “The project’s code-quality gate.” + +--- + +## Summary (Checklist) + +Before suggesting how to lint the project, verify: + +- [ ] You are using `uv run` +- [ ] You are invoking `ruff` +- [ ] You are not inventing alternate linting tools + +If all are true, you are doing it right. + diff --git a/main.py b/main.py index 197bec6..1d66eb1 100644 --- a/main.py +++ b/main.py @@ -32,11 +32,11 @@ def main(): _configure_logging(args) browser = Browser() - + # Enable debug mode in chrome if --debug flag is set if args.debug: browser.chrome.debug_mode = True - + # If no URL provided, use startpage url = args.url if args.url else "about:startpage" browser.new_tab(url) diff --git a/pyproject.toml b/pyproject.toml index 4b88839..3c38159 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "Jinja2>=3.0", # Template engine for pages ] -[project.optional-dependencies] +[dependency-groups] dev = [ "pytest>=9.0.0", "pytest-cov>=7.0.0", @@ -35,12 +35,14 @@ managed = true packages = ["src"] [tool.black] -line-length = 100 +line-length = 120 target-version = ["py311"] [tool.ruff] -line-length = 100 +line-length = 120 target-version = "py311" + +[tool.ruff.lint] select = ["E", "F", "W"] [tool.mypy] diff --git a/src/browser/browser.py b/src/browser/browser.py index c92e34b..40fe7cc 100644 --- a/src/browser/browser.py +++ b/src/browser/browser.py @@ -1,3 +1,4 @@ +# ruff: noqa: E402 """Browser entry and orchestration.""" import gi diff --git a/src/browser/chrome.py b/src/browser/chrome.py index e030d1e..ebbe37c 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -1,3 +1,4 @@ +# ruff: noqa: E402 """Browser chrome (Adwaita UI).""" import gi @@ -34,38 +35,38 @@ class Chrome: self.skia_surface: Optional[skia.Surface] = None self.tab_pages: dict = {} # Map tab objects to AdwTabPage self._closing_tabs: set = set() # Track tabs being closed to prevent re-entry - + # Render pipeline - handles layout and painting self.render_pipeline = RenderPipeline() - + # Debug mode state self.debug_mode = False - + # FPS tracking for debug mode self.frame_times = [] # List of recent frame timestamps self.fps = 0.0 - + # Profiling data self._last_profile = {} self._last_profile_total = 0.0 - + # Scroll state self.scroll_y = 0 self.document_height = 0 # Total document height for scroll limits self.viewport_height = 0 # Current viewport height - + # Scrollbar fade state self.scrollbar_opacity = 0.0 self.scrollbar_fade_timeout = None - + # Selection state self.selection_start = None # (x, y) of selection start self.selection_end = None # (x, y) of selection end self.is_selecting = False # True while mouse is dragging - + # Layout information for text selection (populated from render pipeline) self.text_layout = [] - + # Sub-timings for detailed profiling self._render_sub_timings = {} self._visible_line_count = 0 @@ -76,7 +77,7 @@ class Chrome: if not hasattr(self.browser.app, '_adw_init'): Adw.init() self.browser.app._adw_init = True - + # Create Adwaita window instead of standard GTK window self.window = Adw.ApplicationWindow(application=self.browser.app) self.window.set_default_size(1024, 768) @@ -89,30 +90,30 @@ class Chrome: # Header bar with navigation and address bar header_bar = Gtk.HeaderBar() vbox.append(header_bar) - + # Navigation buttons in header bar nav_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) nav_box.add_css_class("linked") - + self.back_btn = Gtk.Button(label="◀") self.back_btn.set_tooltip_text("Back") self.forward_btn = Gtk.Button(label="▶") self.forward_btn.set_tooltip_text("Forward") self.reload_btn = Gtk.Button(label="⟳") self.reload_btn.set_tooltip_text("Reload") - + nav_box.append(self.back_btn) nav_box.append(self.forward_btn) nav_box.append(self.reload_btn) header_bar.pack_start(nav_box) - + # Address bar - centered in header self.address_bar = Gtk.Entry() self.address_bar.set_placeholder_text("Enter URL...") self.address_bar.set_hexpand(True) self.address_bar.set_max_width_chars(40) header_bar.set_title_widget(self.address_bar) - + # Go button in header bar end self.go_btn = Gtk.Button(label="Go") self.go_btn.add_css_class("suggested-action") @@ -120,26 +121,26 @@ class Chrome: # Create TabView for managing tabs self.tab_view = Adw.TabView() - + # Create TabBar for tab display self.tab_bar = Adw.TabBar() self.tab_bar.set_view(self.tab_view) self.tab_bar.set_autohide(False) - + # Add New Tab button to the tab bar new_tab_btn = Gtk.Button() new_tab_btn.set_icon_name("list-add-symbolic") new_tab_btn.set_tooltip_text("New Tab") new_tab_btn.connect("clicked", lambda _: self.browser.new_tab("about:startpage")) self.tab_bar.set_end_action_widget(new_tab_btn) - + vbox.append(self.tab_bar) - + # Create a container box for content that will hold the drawing area content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) content_box.set_vexpand(True) content_box.set_hexpand(True) - + # Create the drawing area for rendering page content self.drawing_area = Gtk.DrawingArea() self.drawing_area.set_vexpand(True) @@ -148,25 +149,25 @@ class Chrome: self.drawing_area.set_can_focus(True) # Allow focus for keyboard events self.drawing_area.set_focusable(True) content_box.append(self.drawing_area) - + # Add scroll controller for mouse wheel scroll_controller = Gtk.EventControllerScroll.new( Gtk.EventControllerScrollFlags.VERTICAL ) scroll_controller.connect("scroll", self._on_scroll) self.drawing_area.add_controller(scroll_controller) - + # Add mouse button controller for selection click_controller = Gtk.GestureClick.new() click_controller.connect("pressed", self._on_mouse_pressed) click_controller.connect("released", self._on_mouse_released) self.drawing_area.add_controller(click_controller) - + # Add motion controller for drag selection motion_controller = Gtk.EventControllerMotion.new() motion_controller.connect("motion", self._on_mouse_motion) self.drawing_area.add_controller(motion_controller) - + # Add content box to vbox (not to TabView - we use a single drawing area for all tabs) vbox.append(content_box) @@ -192,59 +193,59 @@ class Chrome: self.forward_btn.connect("clicked", lambda _b: self.browser.go_forward()) if self.reload_btn: self.reload_btn.connect("clicked", lambda _b: self.browser.reload()) - + # Connect TabView signals if self.tab_view: self.tab_view.connect("page-attached", self._on_page_attached) self.tab_view.connect("close-page", self._on_close_page) self.tab_view.connect("notify::selected-page", self._on_selected_page_changed) - + # Setup keyboard shortcuts self._setup_keyboard_shortcuts() - + # Add any tabs that were created before the window for tab in self.browser.tabs: if tab not in self.tab_pages: self._add_tab_to_ui(tab) - + # Set the active tab in the UI if self.browser.active_tab and self.browser.active_tab in self.tab_pages: page = self.tab_pages[self.browser.active_tab] self.tab_view.set_selected_page(page) - + # Show the window self.window.present() - + def _add_tab_to_ui(self, tab): """Internal method to add a tab to the TabView UI.""" # Create a simple placeholder for the tab # (Actual rendering happens in the shared drawing_area) placeholder = Gtk.Box() - + # Create the tab page page = self.tab_view.append(placeholder) page.set_title(tab.title) - + # Store mapping self.tab_pages[tab] = page - + # Select this tab self.tab_view.set_selected_page(page) - + def add_tab(self, tab): """Add a tab to the TabView.""" if not self.tab_view: # Window not created yet, tab will be added when window is created return - + self._add_tab_to_ui(tab) - + def update_tab(self, tab): """Update tab title and other properties.""" if tab in self.tab_pages: page = self.tab_pages[tab] page.set_title(tab.title) - + def remove_tab(self, tab): """Remove a tab from the TabView programmatically.""" if tab not in self.tab_pages: @@ -253,20 +254,20 @@ class Chrome: del self.tab_pages[tab] # Directly close the page - this triggers _on_close_page but we've already removed from tab_pages self.tab_view.close_page(page) - + def set_active_tab(self, tab): """Set the active tab in the TabView.""" if tab in self.tab_pages: page = self.tab_pages[tab] self.tab_view.set_selected_page(page) - + def _on_page_attached(self, tab_view, page, position): """Handle when a page is attached to the TabView.""" self.logger.debug(f"Page attached at position {position}") - + def _on_close_page(self, tab_view, page): """Handle tab close request from UI. - + This is called when close_page() is invoked. We must call close_page_finish() to actually complete the page removal. """ @@ -276,7 +277,7 @@ class Chrome: if tab_page == page: tab_to_close = tab break - + if tab_to_close: # Remove from our tracking del self.tab_pages[tab_to_close] @@ -289,7 +290,7 @@ class Chrome: # Page not in our tracking - just confirm the close self.tab_view.close_page_finish(page, True) return True - + def _on_selected_page_changed(self, tab_view, pspec): """Handle tab selection change.""" selected_page = tab_view.get_selected_page() @@ -321,7 +322,7 @@ class Chrome: self.browser.navigate_to(self.address_bar.get_text()) def on_draw(self, drawing_area, context, width, height): - """Callback for drawing the content area using Skia.""" + """Callback for drawing the content area using Skia.""" # Track frame time for FPS calculation current_time = time.time() self.frame_times.append(current_time) @@ -329,17 +330,17 @@ class Chrome: self.frame_times = [t for t in self.frame_times if current_time - t < 1.0] if len(self.frame_times) > 1: self.fps = len(self.frame_times) - + # Profiling timers profile_start = time.perf_counter() timings = {} - + # Create Skia surface for this frame t0 = time.perf_counter() self.skia_surface = skia.Surface(width, height) canvas = self.skia_surface.getCanvas() timings['surface_create'] = time.perf_counter() - t0 - + # Store viewport height self.viewport_height = height @@ -355,11 +356,11 @@ class Chrome: t0 = time.perf_counter() self._render_dom_content(canvas, document, width, height) timings['render_dom'] = time.perf_counter() - t0 - + t0 = time.perf_counter() self._draw_scrollbar(canvas, width, height) timings['scrollbar'] = time.perf_counter() - t0 - + if self.debug_mode: self._draw_fps_counter(canvas, width) else: @@ -373,11 +374,11 @@ class Chrome: t0 = time.perf_counter() image = self.skia_surface.makeImageSnapshot() timings['snapshot'] = time.perf_counter() - t0 - + t0 = time.perf_counter() pixels = image.tobytes() timings['tobytes'] = time.perf_counter() - t0 - + # Create Cairo ImageSurface from raw pixels t0 = time.perf_counter() cairo_surface = cairo.ImageSurface.create_for_data( @@ -388,39 +389,39 @@ class Chrome: width * 4 # stride ) timings['cairo_surface'] = time.perf_counter() - t0 - + # Blit Cairo surface to context t0 = time.perf_counter() context.set_source_surface(cairo_surface, 0, 0) context.paint() timings['cairo_blit'] = time.perf_counter() - t0 - + total_time = time.perf_counter() - profile_start - + # Store profiling data for debug display if self.debug_mode: self._last_profile = timings self._last_profile_total = total_time - + def _render_dom_content(self, canvas, document, width: int, height: int): """Render the DOM content using the render pipeline.""" sub_timings = {} - + # Sync debug mode with render pipeline self.render_pipeline.debug_mode = self.debug_mode - + # Use render pipeline for layout and rendering t0 = time.perf_counter() self.render_pipeline.render(canvas, document, width, height, self.scroll_y) sub_timings['render'] = time.perf_counter() - t0 - + # Get text layout for selection t0 = time.perf_counter() self.text_layout = self.render_pipeline.get_text_layout() self.document_height = self.render_pipeline.get_document_height() sub_timings['get_layout'] = time.perf_counter() - t0 - + # Draw selection highlight (still in chrome as it's UI interaction) t0 = time.perf_counter() if self.selection_start and self.selection_end: @@ -429,61 +430,63 @@ class Chrome: self._draw_text_selection(canvas) canvas.restore() sub_timings['selection'] = time.perf_counter() - t0 - + # Store sub-timings for display if self.debug_mode: self._render_sub_timings = sub_timings - self._visible_line_count = len([l for l in self.text_layout - if self.scroll_y - 50 <= l["y"] + l["font_size"] <= self.scroll_y + height + 50]) + self._visible_line_count = len([ + line for line in self.text_layout + if self.scroll_y - 50 <= line["y"] + line["font_size"] <= self.scroll_y + height + 50 + ]) def _draw_selection_highlight(self, canvas, width: int): """Draw selection highlight rectangle.""" if not self.selection_start or not self.selection_end: return - + x1, y1 = self.selection_start x2, y2 = self.selection_end - + # Normalize coordinates left = min(x1, x2) right = max(x1, x2) top = min(y1, y2) bottom = max(y1, y2) - + paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 80)) # Cornflower blue, semi-transparent paint.setStyle(skia.Paint.kFill_Style) - + rect = skia.Rect.MakeLTRB(left, top, right, bottom) canvas.drawRect(rect, paint) - + def _draw_fps_counter(self, canvas, width: int): """Draw FPS counter and profiling info in top-right corner.""" font = get_font(11) small_font = get_font(9) - + # Calculate panel size based on profile data panel_width = 200 num_profile_lines = len(self._last_profile) + 2 # +2 for FPS and total num_sub_lines = len(self._render_sub_timings) + 1 if self._render_sub_timings else 0 panel_height = 18 + num_profile_lines * 12 + num_sub_lines * 11 + 10 - + # Position in top-right panel_x = width - panel_width - 10 panel_y = 10 - + # Background bg_paint = skia.Paint() bg_paint.setColor(skia.Color(0, 0, 0, 200)) bg_paint.setStyle(skia.Paint.kFill_Style) canvas.drawRect(skia.Rect.MakeLTRB( - panel_x, panel_y, + panel_x, panel_y, panel_x + panel_width, panel_y + panel_height ), bg_paint) - + text_paint = skia.Paint() text_paint.setAntiAlias(True) - + # FPS with color if self.fps >= 50: text_paint.setColor(skia.Color(100, 255, 100, 255)) @@ -491,29 +494,29 @@ class Chrome: text_paint.setColor(skia.Color(255, 255, 100, 255)) else: text_paint.setColor(skia.Color(255, 100, 100, 255)) - + y = panel_y + 14 canvas.drawString(f"FPS: {self.fps:.0f}", panel_x + 5, y, font, text_paint) - + # Total frame time text_paint.setColor(skia.ColorWHITE) total_ms = self._last_profile_total * 1000 y += 14 canvas.drawString(f"Frame: {total_ms:.1f}ms", panel_x + 5, y, font, text_paint) - + # Profile breakdown gray_paint = skia.Paint() gray_paint.setAntiAlias(True) gray_paint.setColor(skia.Color(180, 180, 180, 255)) - + if self._last_profile: # Sort by time descending sorted_items = sorted( - self._last_profile.items(), - key=lambda x: x[1], + self._last_profile.items(), + key=lambda x: x[1], reverse=True ) - + for name, duration in sorted_items: y += 12 ms = duration * 1000 @@ -526,13 +529,19 @@ class Chrome: else: gray_paint.setColor(skia.Color(180, 180, 180, 255)) canvas.drawString(f"{name}: {ms:.1f}ms ({pct:.0f}%)", panel_x + 8, y, small_font, gray_paint) - + # Show render_dom sub-timings if available if self._render_sub_timings: y += 16 text_paint.setColor(skia.Color(150, 200, 255, 255)) - canvas.drawString(f"render_dom breakdown ({self._visible_line_count} lines):", panel_x + 5, y, small_font, text_paint) - + canvas.drawString( + f"render_dom breakdown ({self._visible_line_count} lines):", + panel_x + 5, + y, + small_font, + text_paint, + ) + sub_sorted = sorted(self._render_sub_timings.items(), key=lambda x: x[1], reverse=True) for name, duration in sub_sorted: y += 11 @@ -544,35 +553,35 @@ class Chrome: """Trigger redraw of the drawing area.""" if self.drawing_area: self.drawing_area.queue_draw() - + def _setup_keyboard_shortcuts(self): """Setup keyboard event handling for shortcuts.""" if not self.window: return - + # Create event controller for key presses key_controller = Gtk.EventControllerKey() key_controller.connect("key-pressed", self._on_key_pressed) self.window.add_controller(key_controller) - + def _on_key_pressed(self, controller, keyval, keycode, state): """Handle keyboard shortcuts.""" # Check for Ctrl+Shift+D (DOM graph visualization) ctrl_pressed = state & Gdk.ModifierType.CONTROL_MASK shift_pressed = state & Gdk.ModifierType.SHIFT_MASK - + key_name = Gdk.keyval_name(keyval) - + # Ctrl+Shift+D: DOM graph visualization if ctrl_pressed and shift_pressed and key_name in ('D', 'd'): self._show_dom_graph() return True - + # Ctrl+Shift+O: Toggle debug mode (DOM outline visualization) if ctrl_pressed and shift_pressed and key_name in ('O', 'o'): self._toggle_debug_mode() return True - + # Page scrolling with arrow keys, Page Up/Down, Home/End scroll_amount = 50 if key_name == 'Down': @@ -602,86 +611,86 @@ class Chrome: else: self._scroll_by(400) return True - + return False # Event not handled - + def _toggle_debug_mode(self): """Toggle debug mode for DOM visualization.""" self.debug_mode = not self.debug_mode mode_str = "ON" if self.debug_mode else "OFF" self.logger.info(f"Debug mode: {mode_str}") self.paint() - + def _scroll_by(self, delta: int): """Scroll the page by the given amount, clamped to document bounds.""" max_scroll = max(0, self.document_height - self.viewport_height) self.scroll_y = max(0, min(max_scroll, self.scroll_y + delta)) self._show_scrollbar() self.paint() - + def _show_scrollbar(self): """Show scrollbar and schedule fade out.""" from gi.repository import GLib - + self.scrollbar_opacity = 1.0 - + # Cancel any existing fade timeout if self.scrollbar_fade_timeout: GLib.source_remove(self.scrollbar_fade_timeout) - + # Schedule fade out after 1 second self.scrollbar_fade_timeout = GLib.timeout_add(1000, self._fade_scrollbar) - + def _fade_scrollbar(self): """Gradually fade out the scrollbar.""" from gi.repository import GLib - + self.scrollbar_opacity -= 0.1 if self.scrollbar_opacity <= 0: self.scrollbar_opacity = 0 self.scrollbar_fade_timeout = None self.paint() return False # Stop the timeout - + self.paint() # Continue fading self.scrollbar_fade_timeout = GLib.timeout_add(50, self._fade_scrollbar) return False # This instance is done - + def _draw_scrollbar(self, canvas, width: int, height: int): """Draw the scrollbar overlay.""" if self.scrollbar_opacity <= 0 or self.document_height <= height: return - + # Calculate scrollbar dimensions scrollbar_width = 8 scrollbar_margin = 4 scrollbar_x = width - scrollbar_width - scrollbar_margin - + # Track height (full viewport) track_height = height - 2 * scrollbar_margin - + # Thumb size proportional to viewport/document ratio thumb_ratio = height / self.document_height thumb_height = max(30, track_height * thumb_ratio) - + # Thumb position based on scroll position max_scroll = max(1, self.document_height - height) scroll_ratio = self.scroll_y / max_scroll thumb_y = scrollbar_margin + scroll_ratio * (track_height - thumb_height) - + # Draw track (subtle) alpha = int(30 * self.scrollbar_opacity) track_paint = skia.Paint() track_paint.setColor(skia.Color(0, 0, 0, alpha)) track_paint.setStyle(skia.Paint.kFill_Style) track_rect = skia.RRect.MakeRectXY( - skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin, + skia.Rect.MakeLTRB(scrollbar_x, scrollbar_margin, scrollbar_x + scrollbar_width, height - scrollbar_margin), scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(track_rect, track_paint) - + # Draw thumb alpha = int(150 * self.scrollbar_opacity) thumb_paint = skia.Paint() @@ -693,20 +702,20 @@ class Chrome: scrollbar_width / 2, scrollbar_width / 2 ) canvas.drawRRect(thumb_rect, thumb_paint) - + def _on_scroll(self, controller, dx, dy): """Handle mouse wheel scroll.""" scroll_amount = int(dy * 50) # Scale scroll amount self._scroll_by(scroll_amount) return True - + def _on_mouse_pressed(self, gesture, n_press, x, y): """Handle mouse button press for text selection.""" self.selection_start = (x, y + self.scroll_y) self.selection_end = None self.is_selecting = True self.drawing_area.grab_focus() - + def _on_mouse_released(self, gesture, n_press, x, y): """Handle mouse button release for text selection.""" if self.is_selecting: @@ -719,70 +728,78 @@ class Chrome: # Copy to clipboard self._copy_to_clipboard(selected_text) self.paint() - + def _on_mouse_motion(self, controller, x, y): """Handle mouse motion for drag selection.""" if self.is_selecting: self.selection_end = (x, y + self.scroll_y) self.paint() - + def _draw_text_selection(self, canvas): """Draw selection highlight for selected text at character level.""" if not self.selection_start or not self.selection_end: return - + # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or - (self.selection_start[1] == self.selection_end[1] and + (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end - + paint = skia.Paint() paint.setColor(skia.Color(100, 149, 237, 100)) # Cornflower blue paint.setStyle(skia.Paint.kFill_Style) - + for line_info in self.text_layout: line_top = line_info["y"] line_bottom = line_info["y"] + line_info["height"] line_left = line_info["x"] char_positions = line_info.get("char_positions", []) - text = line_info["text"] - + # text not needed for highlight geometry + # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue - + # Determine selection bounds for this line hl_left = line_left hl_right = line_left + line_info["width"] - + # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: # Find character index at sel_start x start_char_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) - hl_left = line_left + char_positions[start_char_idx] if start_char_idx < len(char_positions) else line_left - + hl_left = ( + line_left + char_positions[start_char_idx] + if start_char_idx < len(char_positions) + else line_left + ) + # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: # Find character index at sel_end x end_char_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) - hl_right = line_left + char_positions[end_char_idx] if end_char_idx < len(char_positions) else hl_right - + hl_right = ( + line_left + char_positions[end_char_idx] + if end_char_idx < len(char_positions) + else hl_right + ) + # Draw highlight if hl_right > hl_left: rect = skia.Rect.MakeLTRB(hl_left, line_top, hl_right, line_bottom) canvas.drawRect(rect, paint) - + def _x_to_char_index(self, x: float, line_x: float, char_positions: list) -> int: """Convert x coordinate to character index within a line.""" rel_x = x - line_x if rel_x <= 0: return 0 - + # Binary search for the character position for i, pos in enumerate(char_positions): if pos >= rel_x: @@ -790,82 +807,82 @@ class Chrome: if i > 0 and (pos - rel_x) > (rel_x - char_positions[i-1]): return i - 1 return i - + return len(char_positions) - 1 - + def _get_selected_text(self) -> str: """Extract text from the current selection at character level.""" if not self.selection_start or not self.selection_end or not self.text_layout: return "" - + # Normalize selection: start should be before end in reading order if (self.selection_start[1] > self.selection_end[1] or - (self.selection_start[1] == self.selection_end[1] and + (self.selection_start[1] == self.selection_end[1] and self.selection_start[0] > self.selection_end[0])): sel_start = self.selection_end sel_end = self.selection_start else: sel_start = self.selection_start sel_end = self.selection_end - + selected_parts = [] - + for line_info in self.text_layout: line_top = line_info["y"] line_bottom = line_info["y"] + line_info["height"] line_left = line_info["x"] char_positions = line_info.get("char_positions", []) text = line_info["text"] - + # Skip lines completely outside selection if line_bottom < sel_start[1] or line_top > sel_end[1]: continue - + start_idx = 0 end_idx = len(text) - + # If this line contains the start of selection if line_top <= sel_start[1] < line_bottom: start_idx = self._x_to_char_index(sel_start[0], line_left, char_positions) - + # If this line contains the end of selection if line_top <= sel_end[1] < line_bottom: end_idx = self._x_to_char_index(sel_end[0], line_left, char_positions) - + # Extract the selected portion if end_idx > start_idx: selected_parts.append(text[start_idx:end_idx]) - + return " ".join(selected_parts) - + def _copy_to_clipboard(self, text: str): """Copy text to system clipboard.""" clipboard = Gdk.Display.get_default().get_clipboard() clipboard.set(text) - + def _show_dom_graph(self): """Generate and display DOM graph for current tab.""" from ..debug.dom_graph import render_dom_graph_to_svg, save_dom_graph, print_dom_tree - + if not self.browser.active_tab: self.logger.warning("No active tab to visualize") return - + frame = self.browser.active_tab.main_frame if not frame or not frame.document: self.logger.warning("No document to visualize") return - + # Generate output path output_dir = Path.home() / ".cache" / "bowser" output_dir.mkdir(parents=True, exist_ok=True) - + # Try SVG first, fallback to DOT svg_path = output_dir / "dom_graph.svg" dot_path = output_dir / "dom_graph.dot" - + self.logger.info("Generating DOM graph...") - + # Print tree to console for debugging tree_text = print_dom_tree(frame.document, max_depth=15) print("\n" + "="*60) @@ -873,7 +890,7 @@ class Chrome: print("="*60) print(tree_text) print("="*60 + "\n") - + # Try to render as SVG if render_dom_graph_to_svg(frame.document, str(svg_path)): # Open in new browser tab @@ -884,7 +901,7 @@ class Chrome: if save_dom_graph(frame.document, str(dot_path)): self.logger.info(f"Opening DOM graph (DOT format) in new tab: {dot_path}") self.browser.new_tab(f"about:dom-graph?path={dot_path}") - + def _show_info_dialog(self, title: str, message: str): """Show an information dialog.""" dialog = Gtk.MessageDialog( diff --git a/src/browser/tab.py b/src/browser/tab.py index f58724b..daf9f18 100644 --- a/src/browser/tab.py +++ b/src/browser/tab.py @@ -1,6 +1,6 @@ """Tab and frame orchestration stubs.""" -from typing import Optional +from typing import Optional, TYPE_CHECKING import logging from ..network.url import URL @@ -8,6 +8,9 @@ from ..network import http from ..parser.html import parse_html, Element from ..templates import render_startpage, render_error_page +if TYPE_CHECKING: + from .browser import Browser + class Frame: def __init__(self, tab: "Tab", parent_frame=None, frame_element=None): @@ -19,7 +22,7 @@ class Frame: def load(self, url: URL, payload: Optional[bytes] = None): """Fetch and parse the URL content.""" logger = logging.getLogger("bowser.frame") - + # Handle special about: URLs url_str = str(url) if url_str.startswith("about:startpage"): @@ -27,7 +30,7 @@ class Frame: self.document = parse_html(html) self.tab.current_url = url return - + if url_str.startswith("about:dom-graph"): # Extract path parameter from urllib.parse import urlparse, parse_qs @@ -35,19 +38,19 @@ class Frame: parsed = urlparse(url_str) params = parse_qs(parsed.query) graph_path = params.get('path', [''])[0] - + html = render_dom_graph_page(graph_path) self.document = parse_html(html) self.tab.current_url = url return - + try: status, content_type, body = http.request(url, payload) - + if status == 200: # Decode response text = body.decode('utf-8', errors='replace') - + # Parse HTML self.document = parse_html(text) self.tab.current_url = url @@ -55,7 +58,7 @@ class Frame: # Error handling - show error page html = render_error_page(status, str(url)) self.document = parse_html(html) - + except Exception as e: # Network error - show error page html = render_error_page(0, str(url), str(e)) diff --git a/src/debug/dom_graph.py b/src/debug/dom_graph.py index da936e2..f147a89 100644 --- a/src/debug/dom_graph.py +++ b/src/debug/dom_graph.py @@ -8,33 +8,33 @@ from ..parser.html import Element, Text def generate_dot_graph(document: Optional[Element]) -> str: """ Generate a Graphviz DOT representation of the DOM tree. - + Args: document: Root element of the DOM tree - + Returns: DOT format string representing the DOM tree """ if not document: return 'digraph DOM {\n label="Empty Document";\n}\n' - + lines = [] lines.append('digraph DOM {') lines.append(' rankdir=TB;') lines.append(' node [shape=box, style=filled];') lines.append('') - + node_counter = [0] # mutable counter for unique IDs - + def escape_label(text: str) -> str: """Escape special characters for DOT labels.""" return text.replace('"', '\\"').replace('\n', '\\n') - + def add_node(node, parent_id: Optional[str] = None) -> str: """Recursively add nodes to the graph.""" node_id = f'node_{node_counter[0]}' node_counter[0] += 1 - + if isinstance(node, Text): # Text nodes text_preview = node.text[:50] + ('...' if len(node.text) > 50 else '') @@ -48,9 +48,9 @@ def generate_dot_graph(document: Optional[Element]) -> str: if len(node.attributes) > 3: attrs_list.append('...') attrs_str = '\\n' + ' '.join(attrs_list) - + label = f'<{escape_label(node.tag)}>{escape_label(attrs_str)}' - + # Color code by tag type if node.tag in ('html', 'body'): color = 'lightgreen' @@ -64,45 +64,45 @@ def generate_dot_graph(document: Optional[Element]) -> str: color = 'lightpink' else: color = 'white' - + lines.append(f' {node_id} [label="{label}", fillcolor={color}];') - + # Add edges to children if hasattr(node, 'children'): for child in node.children: child_id = add_node(child, node_id) lines.append(f' {node_id} -> {child_id};') - + return node_id - + add_node(document) lines.append('}') - + return '\n'.join(lines) def save_dom_graph(document: Optional[Element], output_path: str) -> bool: """ Save DOM tree as a DOT file. - + Args: document: Root element of the DOM tree output_path: Path where to save the .dot file - + Returns: True if successful, False otherwise """ logger = logging.getLogger("bowser.debug") - + try: dot_content = generate_dot_graph(document) - + with open(output_path, 'w', encoding='utf-8') as f: f.write(dot_content) - + logger.info(f"DOM graph saved to {output_path}") return True - + except Exception as e: logger.error(f"Failed to save DOM graph: {e}") return False @@ -111,21 +111,21 @@ def save_dom_graph(document: Optional[Element], output_path: str) -> bool: def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bool: """ Render DOM tree as an SVG image using Graphviz (if available). - + Args: document: Root element of the DOM tree output_path: Path where to save the .svg file - + Returns: True if successful, False otherwise """ logger = logging.getLogger("bowser.debug") - + try: import subprocess - + dot_content = generate_dot_graph(document) - + # Try to render with graphviz result = subprocess.run( ['dot', '-Tsvg', '-o', output_path], @@ -133,14 +133,14 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo capture_output=True, timeout=10 ) - + if result.returncode == 0: logger.info(f"DOM graph rendered to {output_path}") return True else: logger.warning(f"Graphviz rendering failed: {result.stderr.decode()}") return False - + except FileNotFoundError: logger.warning("Graphviz 'dot' command not found. Install graphviz for SVG output.") return False @@ -152,21 +152,21 @@ def render_dom_graph_to_svg(document: Optional[Element], output_path: str) -> bo def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str: """ Generate a text representation of the DOM tree. - + Args: node: DOM node to print indent: Current indentation level max_depth: Maximum depth to traverse - + Returns: String representation of the tree """ if indent > max_depth: return " " * indent + "...\n" - + lines = [] spacer = " " * indent - + if isinstance(node, Text): text_preview = node.text.strip()[:60] if text_preview: @@ -176,11 +176,11 @@ def print_dom_tree(node, indent: int = 0, max_depth: int = 10) -> str: if node.attributes: attrs_preview = {k: v for k, v in list(node.attributes.items())[:3]} attrs_str = f" {attrs_preview}" - + lines.append(f"{spacer}<{node.tag}>{attrs_str}\n") - + if hasattr(node, 'children'): for child in node.children: lines.append(print_dom_tree(child, indent + 1, max_depth)) - + return "".join(lines) diff --git a/src/layout/block.py b/src/layout/block.py index 302a31c..29f0252 100644 --- a/src/layout/block.py +++ b/src/layout/block.py @@ -6,7 +6,7 @@ from ..parser.html import Element, Text class LineLayout: """Layout for a single line of text.""" - + def __init__(self, parent=None): self.parent = parent self.words = [] # List of (text, x, font_size) @@ -15,7 +15,7 @@ class LineLayout: self.width = 0 self.height = 0 self.font_size = 14 - + def add_word(self, word: str, x: float, font_size: int): """Add a word to this line.""" self.words.append((word, x, font_size)) @@ -27,7 +27,7 @@ class LineLayout: class BlockLayout: """Layout for a block-level element.""" - + def __init__(self, node, parent=None, previous=None, frame=None): self.node = node self.parent = parent @@ -44,30 +44,30 @@ class BlockLayout: self.font_size = 14 self.block_type = "block" self.tag = "" - + def layout(self, x: float, y: float, max_width: float): """Layout this block and return the height used.""" self.x = x self.y = y self.width = max_width - + current_y = y + self.margin_top - + # Layout children for child in self.children: child.layout(x, current_y, max_width) current_y += child.height + child.margin_bottom - + # Layout lines for line in self.lines: line.y = current_y current_y += line.height - + self.height = current_y - y + self.margin_bottom return self.height -def build_block_layout(node, parent=None, font_size: int = 14, +def build_block_layout(node, parent=None, font_size: int = 14, margin_top: int = 6, margin_bottom: int = 10, block_type: str = "block", bullet: bool = False) -> BlockLayout: """Build a BlockLayout from a DOM node.""" @@ -77,17 +77,17 @@ def build_block_layout(node, parent=None, font_size: int = 14, block.margin_bottom = margin_bottom block.block_type = block_type block.tag = node.tag if isinstance(node, Element) else "" - + # Collect text content text = _extract_text(node) if bullet and text: text = f"• {text}" - + if text: block._raw_text = text else: block._raw_text = "" - + return block diff --git a/src/layout/document.py b/src/layout/document.py index d9d5476..a87af4c 100644 --- a/src/layout/document.py +++ b/src/layout/document.py @@ -2,12 +2,11 @@ from ..parser.html import Element, Text from ..render.fonts import get_font, linespace -from .block import BlockLayout, LineLayout class LayoutLine: """A laid-out line ready for rendering.""" - + def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None): self.text = text self.x = x @@ -16,7 +15,7 @@ class LayoutLine: self.height = linespace(font_size) self.width = 0 self.char_positions = char_positions or [] - + # Calculate width if text: font = get_font(font_size) @@ -25,7 +24,7 @@ class LayoutLine: class LayoutBlock: """A laid-out block with its lines.""" - + def __init__(self, tag: str, block_type: str = "block"): self.tag = tag self.block_type = block_type @@ -38,7 +37,7 @@ class LayoutBlock: class DocumentLayout: """Layout engine for a document.""" - + def __init__(self, node, frame=None): self.node = node self.frame = frame @@ -46,29 +45,29 @@ class DocumentLayout: self.lines = [] # Flat list of all LayoutLine for rendering self.width = 0 self.height = 0 - + def layout(self, width: int, x_margin: int = 20, y_start: int = 30) -> list: """ Layout the document and return a list of LayoutLine objects. - + Returns: List of LayoutLine objects ready for rendering """ self.width = width max_width = max(10, width - 2 * x_margin) y = y_start - + self.blocks = [] self.lines = [] - + # Find body body = self._find_body(self.node) if not body: return self.lines - + # Collect and layout blocks raw_blocks = self._collect_blocks(body) - + for block_info in raw_blocks: font_size = block_info.get("font_size", 14) text = block_info.get("text", "") @@ -76,26 +75,26 @@ class DocumentLayout: margin_bottom = block_info.get("margin_bottom", 10) block_type = block_info.get("block_type", "block") tag = block_info.get("tag", "") - + if not text: y += font_size * 0.6 continue - + # Optional bullet prefix if block_info.get("bullet"): text = f"• {text}" - + layout_block = LayoutBlock(tag, block_type) layout_block.x = x_margin layout_block.y = y + margin_top - + # Word wrap font = get_font(font_size) words = text.split() wrapped_lines = [] current_line = [] current_width = 0 - + for word in words: word_width = font.measureText(word + " ") if current_width + word_width > max_width and current_line: @@ -107,18 +106,18 @@ class DocumentLayout: current_width += word_width if current_line: wrapped_lines.append(" ".join(current_line)) - + # Create LayoutLines line_height = linespace(font_size) y += margin_top block_start_y = y - + for line_text in wrapped_lines: # Calculate character positions char_positions = [0.0] for i in range(1, len(line_text) + 1): char_positions.append(font.measureText(line_text[:i])) - + layout_line = LayoutLine( text=line_text, x=x_margin, @@ -126,20 +125,20 @@ class DocumentLayout: font_size=font_size, char_positions=char_positions ) - + layout_block.lines.append(layout_line) self.lines.append(layout_line) y += line_height - + layout_block.height = y - block_start_y layout_block.width = max_width self.blocks.append(layout_block) - + y += margin_bottom - + self.height = y + 50 # Padding at bottom return self.lines - + def _find_body(self, node): """Find the body element in the document.""" if isinstance(node, Element) and node.tag == "body": @@ -152,27 +151,27 @@ class DocumentLayout: if found: return found return None - + def _collect_blocks(self, node) -> list: """Collect renderable blocks from the DOM.""" blocks = [] - + for child in getattr(node, "children", []): if isinstance(child, Text): txt = child.text.strip() if txt: blocks.append({"text": txt, "font_size": 14, "block_type": "text"}) continue - + if isinstance(child, Element): tag = child.tag.lower() content = self._text_of(child) if not content: continue - + if tag == "h1": blocks.append({ - "text": content, "font_size": 24, + "text": content, "font_size": 24, "margin_top": 12, "margin_bottom": 12, "block_type": "block", "tag": "h1" }) @@ -215,9 +214,9 @@ class DocumentLayout: "text": content, "font_size": 14, "block_type": "block", "tag": tag }) - + return blocks - + def _text_of(self, node) -> str: """Extract text content from a node.""" if isinstance(node, Text): diff --git a/src/layout/inline.py b/src/layout/inline.py index d58bf01..1f60897 100644 --- a/src/layout/inline.py +++ b/src/layout/inline.py @@ -1,11 +1,11 @@ """Inline and text layout.""" -from ..render.fonts import get_font, measure_text, linespace +from ..render.fonts import get_font, linespace class TextLayout: """Layout for a single word/text run.""" - + def __init__(self, node, word: str, parent=None, previous=None): self.node = node self.word = word @@ -16,7 +16,7 @@ class TextLayout: self.width = 0 self.height = 0 self.font_size = 14 - + def layout(self, font_size: int = 14): """Calculate layout for this text.""" self.font_size = font_size @@ -28,7 +28,7 @@ class TextLayout: class InlineLayout: """Layout for inline content (text runs within a line).""" - + def __init__(self, node, parent=None): self.node = node self.parent = parent @@ -37,14 +37,14 @@ class InlineLayout: self.y = 0 self.width = 0 self.height = 0 - + def add_word(self, word: str, font_size: int = 14): """Add a word to this inline layout.""" text_layout = TextLayout(self.node, word, parent=self) text_layout.layout(font_size) self.children.append(text_layout) return text_layout - + def layout(self, x: float, y: float, max_width: float, font_size: int = 14): """Layout all words, wrapping as needed. Returns list of lines.""" lines = [] @@ -52,7 +52,7 @@ class InlineLayout: current_x = x line_y = y line_height = linespace(font_size) - + for child in self.children: if current_x + child.width > x + max_width and current_line: # Wrap to next line @@ -60,14 +60,14 @@ class InlineLayout: current_line = [] current_x = x line_y += line_height - + child.x = current_x child.y = line_y current_line.append(child) current_x += child.width - + if current_line: lines.append((current_line, line_y, line_height)) - + self.height = line_y + line_height - y if lines else 0 return lines diff --git a/src/network/http.py b/src/network/http.py index 8080f8e..fcaa2f3 100644 --- a/src/network/http.py +++ b/src/network/http.py @@ -7,60 +7,65 @@ import logging from .url import URL -def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_redirects: int = 10) -> Tuple[int, str, bytes]: +def request( + url: URL, + payload: Optional[bytes] = None, + method: str = "GET", + max_redirects: int = 10, +) -> Tuple[int, str, bytes]: """ Fetch a URL and follow redirects, returning (status_code, content_type, body). - + Args: url: URL to fetch payload: Optional request body method: HTTP method (GET, POST, etc.) max_redirects: Maximum number of redirects to follow (default 10) - + Returns: Tuple of (status_code, content_type, response_body) """ logger = logging.getLogger("bowser.network") current_url = url redirect_count = 0 - + while redirect_count < max_redirects: parsed = current_url._parsed conn_class = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection - + try: conn = conn_class(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80)) path = parsed.path or "/" if parsed.query: path = f"{path}?{parsed.query}" - + headers = { "User-Agent": "Bowser/0.0.1", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", } - + logger.info(f"HTTP {method} {parsed.scheme}://{parsed.hostname}{path}") conn.request(method, path, body=payload, headers=headers) resp = conn.getresponse() - + status = resp.status content_type = resp.getheader("Content-Type", "text/html") body = resp.read() - + logger.info(f"HTTP response {status} {resp.reason} ({len(body)} bytes)") - + # Handle redirects (3xx status codes) if 300 <= status < 400 and status != 304: location = resp.getheader("Location") conn.close() - + if not location: logger.warning(f"Redirect response {status} without Location header") return status, content_type, body - + logger.info(f"Following redirect to {location}") redirect_count += 1 - + # Convert relative URLs to absolute if location.startswith("http://") or location.startswith("https://"): current_url = URL(location) @@ -70,21 +75,21 @@ def request(url: URL, payload: Optional[bytes] = None, method: str = "GET", max_ if parsed.port: base_url += f":{parsed.port}" current_url = URL(base_url + location) - + # For 303 (See Other), change method to GET if status == 303: method = "GET" payload = None - + continue - + conn.close() return status, content_type, body - + except Exception as e: logger.error(f"HTTP request failed: {e}") raise - + # Max redirects exceeded logger.error(f"Maximum redirects ({max_redirects}) exceeded") raise Exception(f"Too many redirects (max: {max_redirects})") diff --git a/src/render/fonts.py b/src/render/fonts.py index 4e03756..10be589 100644 --- a/src/render/fonts.py +++ b/src/render/fonts.py @@ -5,22 +5,22 @@ import skia class FontCache: """Cache for Skia fonts and typefaces.""" - + _instance = None - + def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._font_cache = {} cls._instance._default_typeface = None return cls._instance - + def get_typeface(self): """Get the default typeface, creating it if needed.""" if self._default_typeface is None: self._default_typeface = skia.Typeface.MakeDefault() return self._default_typeface - + def get_font(self, size: int, weight: str = "normal", style: str = "normal") -> skia.Font: """Get a cached Skia font for the given parameters.""" key = (size, weight, style) @@ -28,11 +28,11 @@ class FontCache: typeface = self.get_typeface() self._font_cache[key] = skia.Font(typeface, size) return self._font_cache[key] - + def measure_text(self, text: str, font: skia.Font) -> float: """Measure the width of text using the given font.""" return font.measureText(text) - + def get_line_height(self, font_size: int) -> float: """Get the line height for a given font size.""" return font_size * 1.4 diff --git a/src/render/paint.py b/src/render/paint.py index 045d889..e22afc4 100644 --- a/src/render/paint.py +++ b/src/render/paint.py @@ -6,10 +6,10 @@ from .fonts import get_font class PaintCommand: """Base class for paint commands.""" - + def __init__(self, rect): self.rect = rect # (x1, y1, x2, y2) bounding box - + def execute(self, canvas: skia.Canvas): """Execute this paint command on the canvas.""" raise NotImplementedError @@ -17,7 +17,7 @@ class PaintCommand: class DrawText(PaintCommand): """Command to draw text.""" - + def __init__(self, x: float, y: float, text: str, font_size: int, color=None): self.x = x self.y = y @@ -27,7 +27,7 @@ class DrawText(PaintCommand): self._font = get_font(font_size) width = self._font.measureText(text) super().__init__((x, y - font_size, x + width, y)) - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Draw the text on the canvas.""" if paint is None: @@ -39,12 +39,12 @@ class DrawText(PaintCommand): class DrawRect(PaintCommand): """Command to draw a rectangle.""" - + def __init__(self, x1: float, y1: float, x2: float, y2: float, color, fill: bool = True): super().__init__((x1, y1, x2, y2)) self.color = color self.fill = fill - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Draw the rectangle on the canvas.""" if paint is None: @@ -57,21 +57,21 @@ class DrawRect(PaintCommand): class DisplayList: """A list of paint commands to execute.""" - + def __init__(self): self.commands = [] - + def append(self, command: PaintCommand): """Add a paint command.""" self.commands.append(command) - + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): """Execute all commands on the canvas.""" for cmd in self.commands: cmd.execute(canvas, paint) - + def __len__(self): return len(self.commands) - + def __iter__(self): return iter(self.commands) diff --git a/src/render/pipeline.py b/src/render/pipeline.py index 3652d60..f7ae282 100644 --- a/src/render/pipeline.py +++ b/src/render/pipeline.py @@ -5,92 +5,92 @@ from typing import Optional from ..parser.html import Element from ..layout.document import DocumentLayout from .fonts import get_font -from .paint import DisplayList, DrawText, DrawRect +from .paint import DisplayList class RenderPipeline: """Coordinates layout calculation and rendering to a Skia canvas.""" - + def __init__(self): # Layout cache self._layout: Optional[DocumentLayout] = None self._layout_width = 0 self._layout_doc_id = None - + # Paint cache self._text_paint: Optional[skia.Paint] = None self._display_list: Optional[DisplayList] = None - + # Debug mode self.debug_mode = False - + def layout(self, document: Element, width: int) -> DocumentLayout: """ Calculate layout for the document. Returns the DocumentLayout with all positioned elements. """ doc_id = id(document) - + # Check cache - if (self._layout_doc_id == doc_id and - self._layout_width == width and + if (self._layout_doc_id == doc_id and + self._layout_width == width and self._layout is not None): return self._layout - + # Build new layout self._layout = DocumentLayout(document) self._layout.layout(width) self._layout_doc_id = doc_id self._layout_width = width - + return self._layout - - def render(self, canvas: skia.Canvas, document: Element, + + def render(self, canvas: skia.Canvas, document: Element, width: int, height: int, scroll_y: float = 0): """ Render the document to the canvas. - + Args: canvas: Skia canvas to draw on document: DOM document root width: Viewport width - height: Viewport height + height: Viewport height scroll_y: Vertical scroll offset """ # Get or update layout layout = self.layout(document, width) - + if not layout.lines: return - + # Apply scroll transform canvas.save() canvas.translate(0, -scroll_y) - + # Get paint if self._text_paint is None: self._text_paint = skia.Paint() self._text_paint.setAntiAlias(True) self._text_paint.setColor(skia.ColorBLACK) - + # Render visible lines only visible_top = scroll_y - 50 visible_bottom = scroll_y + height + 50 - + for line in layout.lines: baseline_y = line.y + line.font_size if baseline_y < visible_top or line.y > visible_bottom: continue - + font = get_font(line.font_size) canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint) - + # Draw debug overlays if enabled if self.debug_mode: self._render_debug_overlays(canvas, layout) - + canvas.restore() - + def _render_debug_overlays(self, canvas: skia.Canvas, layout: DocumentLayout): """Render debug bounding boxes for layout blocks.""" # Color scheme for different block types @@ -100,35 +100,35 @@ class RenderPipeline: "list-item": (0, 255, 0, 60), # Green "text": (255, 255, 0, 60), # Yellow } - + border_colors = { "block": (255, 0, 0, 180), "inline": (0, 0, 255, 180), "list-item": (0, 255, 0, 180), "text": (255, 255, 0, 180), } - + for block in layout.blocks: block_type = block.block_type - + # Calculate block bounds from lines if not block.lines: continue - + x = block.x - 5 y = block.y - block.lines[0].font_size if block.lines else block.y w = block.width + 10 h = block.height + 5 - + # Fill fill_paint = skia.Paint() c = colors.get(block_type, colors["block"]) fill_paint.setColor(skia.Color(*c)) fill_paint.setStyle(skia.Paint.kFill_Style) - + rect = skia.Rect.MakeLTRB(x, y, x + w, y + h) canvas.drawRect(rect, fill_paint) - + # Border border_paint = skia.Paint() bc = border_colors.get(block_type, border_colors["block"]) @@ -136,7 +136,7 @@ class RenderPipeline: border_paint.setStyle(skia.Paint.kStroke_Style) border_paint.setStrokeWidth(1) canvas.drawRect(rect, border_paint) - + def get_text_layout(self) -> list: """ Get the text layout for text selection. @@ -144,7 +144,7 @@ class RenderPipeline: """ if self._layout is None: return [] - + result = [] for line in self._layout.lines: result.append({ @@ -157,13 +157,13 @@ class RenderPipeline: "char_positions": line.char_positions }) return result - + def get_document_height(self) -> float: """Get the total document height for scrolling.""" if self._layout is None: return 0 return self._layout.height - + def invalidate(self): """Invalidate the layout cache, forcing recalculation.""" self._layout = None diff --git a/src/templates.py b/src/templates.py index b5de9d9..e23a349 100644 --- a/src/templates.py +++ b/src/templates.py @@ -1,6 +1,5 @@ """Template rendering utilities.""" -import os from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape import logging @@ -12,11 +11,11 @@ def get_template_env(): # The templates.py file is at src/templates.py, so parent.parent gets to project root current_file = Path(__file__) pages_dir = current_file.parent.parent / "assets" / "pages" - + # If pages_dir doesn't exist, try alternative path (for tests) if not pages_dir.exists(): pages_dir = Path("/home/bw/Workspace/bowser/assets/pages") - + env = Environment( loader=FileSystemLoader(str(pages_dir)), autoescape=select_autoescape(['html', 'xml']) @@ -27,24 +26,24 @@ def get_template_env(): def render_template(template_name: str, **context) -> str: """ Render a template with the given context. - + Args: template_name: Name of the template file (e.g., 'startpage.html') **context: Variables to pass to the template - + Returns: Rendered HTML string """ logger = logging.getLogger("bowser.templates") - + try: env = get_template_env() template = env.get_template(template_name) - + # Set default version if not provided if 'version' not in context: context['version'] = '0.0.1' - + logger.debug(f"Rendering template: {template_name}") return template.render(**context) except Exception as e: @@ -55,32 +54,26 @@ def render_template(template_name: str, **context) -> str: def render_error_page(status_code: int, url: str = "", error_message: str = "") -> str: """ Render an error page for the given status code. - + Args: status_code: HTTP status code (404, 500, etc.) url: URL that caused the error error_message: Optional error details - + Returns: Rendered error HTML """ logger = logging.getLogger("bowser.templates") - - # Map common status codes to templates - template_map = { - 404: "error_404.html", - 500: "error_500.html", - # Network errors - "network": "error_network.html", - } - + + # Determine template per status + if status_code == 404: template = "error_404.html" elif status_code >= 500: template = "error_500.html" else: template = "error_network.html" - + try: env = get_template_env() tmpl = env.get_template(template) @@ -104,40 +97,40 @@ def render_startpage() -> str: def render_dom_graph_page(graph_path: str) -> str: """ Render the DOM graph visualization page. - + Args: graph_path: Path to the SVG or DOT file - + Returns: Rendered HTML with embedded graph """ from pathlib import Path - + logger = logging.getLogger("bowser.templates") graph_path_obj = Path(graph_path) - + if not graph_path_obj.exists(): logger.error(f"Graph file not found: {graph_path}") - return render_template("dom_graph.html", + return render_template("dom_graph.html", error="Graph file not found", graph_content="", is_svg=False) - + try: # Check file type is_svg = graph_path_obj.suffix == '.svg' - + # Read the file with open(graph_path, 'r', encoding='utf-8') as f: graph_content = f.read() - + logger.info(f"Rendering DOM graph from {graph_path}") return render_template("dom_graph.html", graph_content=graph_content, is_svg=is_svg, graph_path=str(graph_path), error=None) - + except Exception as e: logger.error(f"Failed to read graph file {graph_path}: {e}") return render_template("dom_graph.html", diff --git a/tests/test_browser.py b/tests/test_browser.py index 863f957..ea113e4 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -1,6 +1,5 @@ """Tests for browser tab management.""" -import pytest from unittest.mock import Mock, patch from src.browser.browser import Browser from src.browser.tab import Tab @@ -15,101 +14,101 @@ class TestTab: assert tab.current_url is None assert tab.history == [] assert tab.history_index == -1 - + def test_tab_title_new(self): browser = Mock() tab = Tab(browser) assert tab.title == "New Tab" - + def test_tab_title_with_url(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) assert "example.com" in tab.title - + def test_tab_load_adds_history(self): browser = Mock() browser._log = Mock() tab = Tab(browser) url1 = URL("https://example.com") url2 = URL("https://other.com") - + tab.load(url1) assert len(tab.history) == 1 assert tab.history_index == 0 - + tab.load(url2) assert len(tab.history) == 2 assert tab.history_index == 1 - + def test_tab_go_back(self): browser = Mock() browser._log = Mock() tab = Tab(browser) url1 = URL("https://example.com") url2 = URL("https://other.com") - + tab.load(url1) tab.load(url2) - + result = tab.go_back() assert result is True assert tab.history_index == 0 - + def test_tab_go_back_at_start(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.go_back() assert result is False assert tab.history_index == 0 - + def test_tab_go_forward(self): browser = Mock() browser._log = Mock() tab = Tab(browser) - + tab.load(URL("https://example.com")) tab.load(URL("https://other.com")) tab.go_back() - + result = tab.go_forward() assert result is True assert tab.history_index == 1 - + def test_tab_go_forward_at_end(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.go_forward() assert result is False - + def test_tab_reload(self): browser = Mock() browser._log = Mock() tab = Tab(browser) tab.load(URL("https://example.com")) - + result = tab.reload() assert result is True assert tab.history_index == 0 - + def test_tab_history_truncation(self): browser = Mock() browser._log = Mock() tab = Tab(browser) - + tab.load(URL("https://example.com")) tab.load(URL("https://other.com")) tab.load(URL("https://third.com")) tab.go_back() # now at other.com tab.load(URL("https://new.com")) # should truncate third.com - + assert len(tab.history) == 3 assert tab.history_index == 2 @@ -120,91 +119,91 @@ class TestBrowser: browser = Browser() assert browser.tabs == [] assert browser.active_tab is None - + def test_new_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() - + tab = browser.new_tab("https://example.com") - + assert len(browser.tabs) == 1 assert browser.active_tab is tab assert tab in browser.tabs - + def test_set_active_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab1 = browser.new_tab("https://example.com") - tab2 = browser.new_tab("https://other.com") - + _ = browser.new_tab("https://other.com") + browser.set_active_tab(tab1) assert browser.active_tab is tab1 - + def test_close_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab1 = browser.new_tab("https://example.com") tab2 = browser.new_tab("https://other.com") - + browser.close_tab(tab1) - + assert len(browser.tabs) == 1 assert tab1 not in browser.tabs assert browser.active_tab is tab2 - + def test_close_active_tab_selects_previous(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - - tab1 = browser.new_tab("https://example.com") + + _ = browser.new_tab("https://example.com") tab2 = browser.new_tab("https://other.com") tab3 = browser.new_tab("https://third.com") - + browser.close_tab(tab3) assert browser.active_tab is tab2 - + def test_close_last_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() browser.chrome.tabs_box = Mock() - + tab = browser.new_tab("https://example.com") browser.close_tab(tab) - + assert len(browser.tabs) == 0 assert browser.active_tab is None - + def test_navigate_to(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() browser.chrome.paint = Mock() - + tab = browser.new_tab("https://example.com") browser.navigate_to("https://other.com") - + assert len(tab.history) == 2 - + def test_navigate_to_no_active_tab(self, mock_gtk): browser = Browser() browser.chrome.rebuild_tab_bar = Mock() browser.chrome.update_address_bar = Mock() - + browser.navigate_to("https://example.com") - + assert len(browser.tabs) == 1 assert browser.active_tab is not None diff --git a/tests/test_cookies.py b/tests/test_cookies.py index eb4bda0..b6af30b 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -1,6 +1,5 @@ """Tests for cookie management.""" -import pytest from src.network.cookies import CookieJar @@ -8,34 +7,34 @@ class TestCookieJar: def test_cookie_jar_creation(self): jar = CookieJar() assert jar._cookies == {} - + def test_set_cookies(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") - + cookies = jar.get_cookie_header("https://example.com") assert "session=abc123" in cookies - + def test_get_cookie_header_empty(self): jar = CookieJar() cookies = jar.get_cookie_header("https://example.com") assert cookies == "" - + def test_multiple_cookies_same_origin(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") jar.set_cookies("https://example.com", "user=john") - + cookies = jar.get_cookie_header("https://example.com") assert "session=abc123" in cookies or "user=john" in cookies - + def test_cookies_isolated_by_origin(self): jar = CookieJar() jar.set_cookies("https://example.com", "session=abc123") jar.set_cookies("https://other.com", "session=xyz789") - + cookies1 = jar.get_cookie_header("https://example.com") cookies2 = jar.get_cookie_header("https://other.com") - + assert "abc123" in cookies1 assert "xyz789" in cookies2 diff --git a/tests/test_dom_graph.py b/tests/test_dom_graph.py index 879f76b..6b53cc0 100644 --- a/tests/test_dom_graph.py +++ b/tests/test_dom_graph.py @@ -1,7 +1,6 @@ """Tests for DOM graph visualization.""" -import pytest -from src.parser.html import parse_html, Element, Text +from src.parser.html import parse_html from src.debug.dom_graph import generate_dot_graph, print_dom_tree @@ -9,36 +8,36 @@ class TestDOMGraph: def test_generate_dot_graph_empty(self): """Test generating graph for None document.""" dot = generate_dot_graph(None) - + assert "digraph DOM" in dot assert "Empty Document" in dot - + def test_generate_dot_graph_simple(self): """Test generating graph for simple HTML.""" html = "

Hello World

" doc = parse_html(html) - + dot = generate_dot_graph(doc) - + assert "digraph DOM" in dot assert "node_" in dot # Should have node IDs assert "" in dot assert "" in dot assert "

" in dot assert "Hello World" in dot - + def test_generate_dot_graph_with_attributes(self): """Test graph generation with element attributes.""" html = '

Content
' doc = parse_html(html) - + dot = generate_dot_graph(doc) - + assert "digraph DOM" in dot assert "
" in dot # Attributes should be included (at least some of them) assert "class" in dot or "id" in dot - + def test_generate_dot_graph_nested(self): """Test graph generation with nested elements.""" html = """ @@ -52,75 +51,75 @@ class TestDOMGraph: """ doc = parse_html(html) - + dot = generate_dot_graph(doc) - + assert "digraph DOM" in dot assert "->" in dot # Should have edges assert "First" in dot assert "Second" in dot - + def test_generate_dot_graph_colors(self): """Test that different element types get different colors.""" html = "

Title

Text

" doc = parse_html(html) - + dot = generate_dot_graph(doc) - + # Check for color attributes assert "fillcolor=" in dot assert "lightgreen" in dot or "lightyellow" in dot or "lightgray" in dot - + def test_print_dom_tree_simple(self): """Test text tree representation.""" html = "

Hello

" doc = parse_html(html) - + tree = print_dom_tree(doc) - + assert "" in tree assert "" in tree assert "

" in tree assert "Hello" in tree - + def test_print_dom_tree_indentation(self): """Test that tree has proper indentation.""" html = "

Nested

" doc = parse_html(html) - + tree = print_dom_tree(doc) - + # Should have increasing indentation lines = tree.split('\n') # Find the nested

line - should be more indented than

- p_line = [l for l in lines if '

' in l][0] - div_line = [l for l in lines if '

' in l][0] - + p_line = [line for line in lines if '

' in line][0] + div_line = [line for line in lines if '

' in line][0] + # Count leading spaces p_indent = len(p_line) - len(p_line.lstrip()) div_indent = len(div_line) - len(div_line.lstrip()) - + assert p_indent > div_indent - + def test_print_dom_tree_max_depth(self): """Test that max_depth limits tree traversal.""" html = "

Deep

" doc = parse_html(html) - + tree_shallow = print_dom_tree(doc, max_depth=2) tree_deep = print_dom_tree(doc, max_depth=10) - + # Shallow should be shorter assert len(tree_shallow) < len(tree_deep) assert "..." in tree_shallow - + def test_generate_dot_graph_text_escaping(self): """Test that special characters in text are escaped.""" html = '

Text with "quotes" and newlines\n

' doc = parse_html(html) - + dot = generate_dot_graph(doc) - + # Should have escaped quotes assert '\\"' in dot or 'quotes' in dot # Should not have raw newlines breaking the DOT format @@ -129,14 +128,14 @@ class TestDOMGraph: for line in lines: if line.strip(): assert not line.strip().startswith('"') or line.strip().endswith(';') or line.strip().endswith(']') - + def test_generate_dot_graph_long_text_truncation(self): """Test that very long text nodes are truncated.""" long_text = "A" * 100 html = f"

{long_text}

" doc = parse_html(html) - + dot = generate_dot_graph(doc) - + # Should contain truncation marker assert "..." in dot diff --git a/tests/test_dom_graph_page.py b/tests/test_dom_graph_page.py index 6bfa931..09c78e3 100644 --- a/tests/test_dom_graph_page.py +++ b/tests/test_dom_graph_page.py @@ -1,8 +1,6 @@ """Tests for DOM graph page rendering.""" -import pytest from src.templates import render_dom_graph_page -from pathlib import Path import tempfile import os @@ -14,10 +12,10 @@ class TestDOMGraphPage: with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f: f.write('') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + assert html assert "DOM" in html assert "Visualization" in html or "Graph" in html @@ -26,17 +24,17 @@ class TestDOMGraphPage: assert temp_path in html # Should show file path finally: os.unlink(temp_path) - + def test_render_dom_graph_page_dot(self): """Test rendering page with DOT graph.""" # Create temporary DOT file with tempfile.NamedTemporaryFile(mode='w', suffix='.dot', delete=False) as f: f.write('digraph G { A -> B; }') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + assert html assert "digraph G" in html # HTML escapes -> as > @@ -44,23 +42,23 @@ class TestDOMGraphPage: assert "Graphviz" in html # Should suggest installing graphviz finally: os.unlink(temp_path) - + def test_render_dom_graph_page_missing_file(self): """Test error handling for missing file.""" html = render_dom_graph_page("/nonexistent/path/to/graph.svg") - + assert html assert "error" in html.lower() or "not found" in html.lower() - + def test_render_dom_graph_page_has_legend(self): """Test that SVG page includes color legend.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f: f.write('') temp_path = f.name - + try: html = render_dom_graph_page(temp_path) - + # Should have legend explaining colors assert 'legend' in html.lower() or 'color' in html.lower() finally: diff --git a/tests/test_frame.py b/tests/test_frame.py index 725b620..634289e 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -1,8 +1,7 @@ """Tests for Frame and content loading.""" -import pytest from unittest.mock import Mock, patch -from src.browser.tab import Frame, Tab +from src.browser.tab import Tab from src.network.url import URL @@ -10,66 +9,66 @@ class TestFrame: @patch('src.browser.tab.http.request') def test_frame_load_success(self, mock_request): mock_request.return_value = (200, "text/html", b"Test") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com") frame.load(url) - + assert frame.document is not None assert frame.document.tag == "html" assert tab.current_url == url - + @patch('src.browser.tab.http.request') def test_frame_load_404(self, mock_request): mock_request.return_value = (404, "text/html", b"Not Found") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com/missing") frame.load(url) - + # Should create error document assert frame.document is not None # Error message in document text = frame.document.children[0].children[0].text if frame.document.children else "" assert "404" in text or "Error" in text - + @patch('src.browser.tab.http.request') def test_frame_load_network_error(self, mock_request): mock_request.side_effect = Exception("Network unreachable") - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://unreachable.example.com") frame.load(url) - + # Should create error document assert frame.document is not None text = frame.document.children[0].children[0].text if frame.document.children else "" assert "Error" in text or "unreachable" in text - + @patch('src.browser.tab.http.request') def test_frame_load_utf8_decode(self, mock_request): mock_request.return_value = (200, "text/html", "Héllo Wörld".encode('utf-8')) - + browser = Mock() browser._log = Mock() tab = Tab(browser) frame = tab.main_frame - + url = URL("http://example.com") frame.load(url) - + assert frame.document is not None # Should handle UTF-8 characters text = frame.document.children[0].children[0].text diff --git a/tests/test_html_parsing.py b/tests/test_html_parsing.py index d129862..f911b8a 100644 --- a/tests/test_html_parsing.py +++ b/tests/test_html_parsing.py @@ -1,6 +1,5 @@ """Tests for HTML parsing functionality.""" -import pytest from src.parser.html import parse_html, Text, Element @@ -18,70 +17,70 @@ class TestParseHTML: def test_parse_simple_text(self): html = "Hello World" root = parse_html(html) - + assert isinstance(root, Element) assert root.tag == "html" assert len(root.children) == 1 - + body = root.children[0] assert body.tag == "body" texts = collect_text(body) joined = " ".join(texts) assert "Hello World" in joined - + def test_parse_strips_tags(self): html = "

Hello

World
" root = parse_html(html) - + body = root.children[0] joined = " ".join(collect_text(body)) assert "Hello" in joined assert "World" in joined - + def test_parse_removes_script_tags(self): html = "VisibleText" root = parse_html(html) - + body = root.children[0] joined = " ".join(collect_text(body)) assert "Visible" in joined assert "Text" in joined assert "alert" not in joined assert "script" not in joined.lower() - + def test_parse_removes_style_tags(self): html = "TextMore" root = parse_html(html) - + body = root.children[0] joined = " ".join(collect_text(body)) assert "Text" in joined assert "More" in joined assert "color" not in joined - + def test_parse_decodes_entities(self): html = "<div> & "test"" root = parse_html(html) - + body = root.children[0] joined = " ".join(collect_text(body)) assert "
" in joined assert "&" in joined assert '"test"' in joined - + def test_parse_normalizes_whitespace(self): html = "Hello \n\n World" root = parse_html(html) - + body = root.children[0] joined = " ".join(collect_text(body)) # Multiple whitespace should be collapsed assert "Hello World" in joined - + def test_parse_empty_document(self): html = "" root = parse_html(html) - + assert isinstance(root, Element) assert root.tag == "html" body = root.children[0] diff --git a/tests/test_http.py b/tests/test_http.py index 388fc3a..5ee1038 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -1,7 +1,7 @@ """Tests for HTTP functionality.""" import pytest -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import Mock, patch from src.network.url import URL from src.network import http @@ -16,18 +16,18 @@ class TestHTTPRequest: mock_response.reason = "OK" mock_response.getheader.return_value = "text/html" mock_response.read.return_value = b"Hello" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com/page") status, content_type, body = http.request(url) - + assert status == 200 assert content_type == "text/html" assert body == b"Hello" - + @patch('src.network.http.http.client.HTTPSConnection') def test_https_request(self, mock_conn_class): # Setup mock @@ -37,18 +37,18 @@ class TestHTTPRequest: mock_response.reason = "OK" mock_response.getheader.return_value = "text/html" mock_response.read.return_value = b"Secure content" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test url = URL("https://example.com") status, content_type, body = http.request(url) - + assert status == 200 assert b"Secure" in body mock_conn_class.assert_called_once() - + @patch('src.network.http.http.client.HTTPConnection') def test_http_request_404(self, mock_conn_class): # Setup mock @@ -58,16 +58,16 @@ class TestHTTPRequest: mock_response.reason = "Not Found" mock_response.getheader.return_value = "text/html" mock_response.read.return_value = b"Not Found" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com/missing") status, content_type, body = http.request(url) - + assert status == 404 - + @patch('src.network.http.http.client.HTTPConnection') def test_http_request_with_user_agent(self, mock_conn_class): # Setup mock @@ -77,20 +77,20 @@ class TestHTTPRequest: mock_response.reason = "OK" mock_response.getheader.return_value = "text/html" mock_response.read.return_value = b"content" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com") http.request(url) - + # Verify User-Agent header was sent call_args = mock_conn.request.call_args headers = call_args[1]['headers'] assert 'User-Agent' in headers assert 'Bowser' in headers['User-Agent'] - + @patch('src.network.http.http.client.HTTPConnection') def test_http_redirect_301(self, mock_conn_class): """Test following 301 permanent redirect.""" @@ -104,7 +104,7 @@ class TestHTTPRequest: "Location": "http://example.com/new-page" }.get(header, default) mock_response_redirect.read.return_value = b"Redirect" - + # Setup mock for second request (final response) mock_response_final = Mock() mock_response_final.status = 200 @@ -113,18 +113,18 @@ class TestHTTPRequest: "Content-Type": "text/html", }.get(header, default) mock_response_final.read.return_value = b"Final content" - + mock_conn.getresponse.side_effect = [mock_response_redirect, mock_response_final] mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com/old-page") status, content_type, body = http.request(url) - + assert status == 200 assert body == b"Final content" assert mock_conn.request.call_count == 2 - + @patch('src.network.http.http.client.HTTPConnection') def test_http_redirect_302(self, mock_conn_class): """Test following 302 temporary redirect.""" @@ -138,7 +138,7 @@ class TestHTTPRequest: "Location": "http://example.com/temp-page" }.get(header, default) mock_response_redirect.read.return_value = b"Redirect" - + # Setup mock for second request (final response) mock_response_final = Mock() mock_response_final.status = 200 @@ -147,17 +147,17 @@ class TestHTTPRequest: "Content-Type": "text/html", }.get(header, default) mock_response_final.read.return_value = b"Temp content" - + mock_conn.getresponse.side_effect = [mock_response_redirect, mock_response_final] mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com/old-page") status, content_type, body = http.request(url) - + assert status == 200 assert body == b"Temp content" - + @patch('src.network.http.http.client.HTTPConnection') def test_http_redirect_no_location(self, mock_conn_class): """Test handling of redirect without Location header.""" @@ -170,18 +170,18 @@ class TestHTTPRequest: "Content-Type": "text/html", }.get(header, default) mock_response.read.return_value = b"Redirect" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test url = URL("http://example.com/page") status, content_type, body = http.request(url) - + # Should return the redirect response if no Location header assert status == 302 assert body == b"Redirect" - + @patch('src.network.http.http.client.HTTPConnection') def test_http_max_redirects(self, mock_conn_class): """Test that max redirects limit is enforced.""" @@ -194,10 +194,10 @@ class TestHTTPRequest: "Location": "http://example.com/redirect-loop" }.get(header, default) mock_response.read.return_value = b"" - + mock_conn.getresponse.return_value = mock_response mock_conn_class.return_value = mock_conn - + # Test with max_redirects=2 url = URL("http://example.com/page") with pytest.raises(Exception, match="Too many redirects"): diff --git a/tests/test_layout.py b/tests/test_layout.py index 80dc303..2447535 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -1,8 +1,9 @@ +# ruff: noqa: E402 """Tests for layout components.""" import pytest import sys -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock # Mock skia before importing layout modules mock_skia = MagicMock() @@ -21,19 +22,19 @@ 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) @@ -42,13 +43,13 @@ class TestLayoutLine: 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" @@ -56,14 +57,14 @@ class TestLayoutBlock: 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.blocks == [] assert layout.lines == [] - + def test_document_layout_finds_body(self): # Create HTML structure: html > body > p html = Element("html") @@ -72,46 +73,46 @@ class TestDocumentLayout: p.children = [Text("Hello world")] body.children = [p] html.children = [body] - + 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) 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") @@ -119,35 +120,40 @@ class TestDocumentLayout: 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")] + 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 @@ -156,20 +162,20 @@ class TestDocumentLayout: 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) @@ -185,13 +191,13 @@ class TestBlockLayout: class TestLineLayout: """Tests for LineLayout class.""" - + def test_line_layout_creation(self): 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) @@ -201,19 +207,19 @@ class TestLineLayout: 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_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") @@ -225,20 +231,20 @@ class TestTextLayout: class TestInlineLayout: """Tests for InlineLayout class.""" - + def test_inline_layout_creation(self): node = Text("Hello") 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) @@ -251,7 +257,7 @@ class TestInlineLayout: 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")] @@ -259,13 +265,13 @@ class TestBuildBlockLayout: 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")] diff --git a/tests/test_parser.py b/tests/test_parser.py index c071e91..e040889 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,6 +1,5 @@ """Tests for HTML parsing.""" -import pytest from src.parser.html import Text, Element, print_tree @@ -9,22 +8,22 @@ class TestHTMLElements: text = Text("Hello World") assert text.text == "Hello World" assert text.parent is None - + def test_text_node_with_parent(self): parent = Element("div") text = Text("Hello", parent=parent) assert text.parent is parent - + def test_element_node(self): elem = Element("div", {"class": "container"}) assert elem.tag == "div" assert elem.attributes == {"class": "container"} assert elem.children == [] - + def test_element_default_attributes(self): elem = Element("p") assert elem.attributes == {} - + def test_element_parent(self): parent = Element("body") child = Element("div", parent=parent) @@ -37,14 +36,14 @@ class TestPrintTree: print_tree(elem) captured = capsys.readouterr() assert "Element('div'" in captured.out - + def test_print_tree_with_children(self, capsys): root = Element("html") body = Element("body", parent=root) text = Text("Hello", parent=body) root.children = [body] body.children = [text] - + print_tree(root) captured = capsys.readouterr() assert "Element('html'" in captured.out diff --git a/tests/test_render.py b/tests/test_render.py index 9ab2239..ab97705 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -1,8 +1,9 @@ +# ruff: noqa: E402 """Tests for rendering primitives.""" import pytest import sys -from unittest.mock import Mock, patch, MagicMock +from unittest.mock import MagicMock # Mock skia before importing render modules mock_skia = MagicMock() @@ -20,7 +21,7 @@ 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) @@ -28,18 +29,18 @@ class TestPaintCommands: class TestDrawText: """Tests for DrawText paint command.""" - + def test_draw_text_creation(self): cmd = DrawText(10, 20, "Hello", 14) assert cmd.x == 10 assert cmd.y == 20 assert cmd.text == "Hello" 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 @@ -49,15 +50,15 @@ class TestDrawText: 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) @@ -67,48 +68,48 @@ class TestDrawRect: 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") @@ -118,19 +119,19 @@ class TestCompositedLayer: 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) @@ -143,30 +144,30 @@ class TestFontCache: class TestFontFunctions: """Tests for font module functions.""" - + def test_get_font(self): font = get_font(14) 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): 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) diff --git a/tests/test_templates.py b/tests/test_templates.py index 4f87530..6dd3eac 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,6 +1,5 @@ """Tests for template rendering.""" -import pytest from src.templates import render_template, render_error_page, render_startpage @@ -8,51 +7,51 @@ class TestTemplateRendering: def test_render_startpage(self): """Test rendering the startpage template.""" html = render_startpage() - + assert html assert "Bowser" in html assert "Welcome" in html assert "" in html - + def test_render_startpage_has_version(self): """Test that startpage includes version.""" html = render_startpage() - + assert "0.0.1" in html - + def test_render_error_404(self): """Test rendering 404 error page.""" html = render_error_page(404, "http://example.com/missing") - + assert html assert "404" in html assert "example.com/missing" in html assert "Not Found" in html - + def test_render_error_500(self): """Test rendering 500 error page.""" html = render_error_page(500, "http://example.com/error") - + assert html assert "500" in html assert "Server Error" in html - + def test_render_error_network(self): """Test rendering network error page.""" html = render_error_page(0, "http://example.com", "Connection refused") - + assert html assert "Network Error" in html assert "Connection refused" in html - + def test_render_error_with_custom_context(self): """Test error page with custom error message.""" html = render_error_page(404, "http://example.com", "Custom error message") - + assert "Custom error message" in html - + def test_render_template_with_context(self): """Test rendering template with custom context.""" html = render_template("startpage.html", version="1.0.0") - + assert "1.0.0" in html diff --git a/tests/test_url.py b/tests/test_url.py index fd0dc95..70c8abe 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -1,6 +1,5 @@ """Tests for URL parsing and resolution.""" -import pytest from src.network.url import URL @@ -8,33 +7,33 @@ class TestURL: def test_parse_simple_url(self): url = URL("https://example.com") assert str(url) == "https://example.com" - + def test_parse_url_with_path(self): url = URL("https://example.com/path/to/page") assert str(url) == "https://example.com/path/to/page" - + def test_parse_url_with_query(self): url = URL("https://example.com/search?q=test") assert str(url) == "https://example.com/search?q=test" - + def test_origin(self): url = URL("https://example.com:8080/path") assert url.origin() == "https://example.com:8080" - + def test_origin_default_port(self): url = URL("https://example.com/path") assert url.origin() == "https://example.com" - + def test_resolve_relative_path(self): base = URL("https://example.com/dir/page.html") resolved = base.resolve("other.html") assert str(resolved) == "https://example.com/dir/other.html" - + def test_resolve_absolute_path(self): base = URL("https://example.com/dir/page.html") resolved = base.resolve("/root/page.html") assert str(resolved) == "https://example.com/root/page.html" - + def test_resolve_full_url(self): base = URL("https://example.com/page.html") resolved = base.resolve("https://other.com/page.html") diff --git a/tests/test_url_normalization.py b/tests/test_url_normalization.py index 1fa7c08..e9a837e 100644 --- a/tests/test_url_normalization.py +++ b/tests/test_url_normalization.py @@ -1,6 +1,5 @@ """Tests for URL normalization.""" -import pytest from src.browser.browser import Browser @@ -8,61 +7,61 @@ class TestURLNormalization: def setup_method(self): """Create a browser instance for each test.""" self.browser = Browser() - + def test_normalize_url_with_https(self): """Test that URLs with https:// protocol are unchanged.""" url = "https://example.com" normalized = self.browser._normalize_url(url) assert normalized == "https://example.com" - + def test_normalize_url_with_http(self): """Test that URLs with http:// protocol are unchanged.""" url = "http://example.com" normalized = self.browser._normalize_url(url) assert normalized == "http://example.com" - + def test_normalize_url_without_protocol(self): """Test that URLs without protocol get https:// added.""" url = "example.com" normalized = self.browser._normalize_url(url) assert normalized == "https://example.com" - + def test_normalize_url_with_path(self): """Test that URLs with path but no protocol get https:// added.""" url = "example.com/path/to/page" normalized = self.browser._normalize_url(url) assert normalized == "https://example.com/path/to/page" - + def test_normalize_url_with_about(self): """Test that about: URLs are not modified.""" url = "about:startpage" normalized = self.browser._normalize_url(url) assert normalized == "about:startpage" - + def test_normalize_url_strips_whitespace(self): """Test that leading/trailing whitespace is stripped.""" url = " example.com " normalized = self.browser._normalize_url(url) assert normalized == "https://example.com" - + def test_normalize_url_with_query_string(self): """Test that URLs with query strings work correctly.""" url = "example.com/search?q=test" normalized = self.browser._normalize_url(url) assert normalized == "https://example.com/search?q=test" - + def test_normalize_url_with_subdomain(self): """Test that subdomains work correctly.""" url = "www.example.com" normalized = self.browser._normalize_url(url) assert normalized == "https://www.example.com" - + def test_normalize_url_with_port(self): """Test that ports are preserved.""" url = "example.com:8080" normalized = self.browser._normalize_url(url) assert normalized == "https://example.com:8080" - + def test_normalize_url_localhost(self): """Test that localhost URLs work correctly.""" url = "localhost:3000" diff --git a/uv.lock b/uv.lock index 4610b25..cd30132 100644 --- a/uv.lock +++ b/uv.lock @@ -49,7 +49,7 @@ dependencies = [ { name = "skia-python" }, ] -[package.optional-dependencies] +[package.dev-dependencies] dev = [ { name = "black" }, { name = "mypy" }, @@ -60,16 +60,19 @@ dev = [ [package.metadata] requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=25.0" }, { name = "jinja2", specifier = ">=3.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.9.0" }, { name = "pygobject", specifier = ">=3.54.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "skia-python", specifier = ">=87.9" }, ] -provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=25.0" }, + { name = "mypy", specifier = ">=1.9.0" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.9.0" }, +] [[package]] name = "click"