From 1d20fb86508139e6df59f6fe455121c31da3fb4d Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 29 Nov 2025 18:15:20 +0100 Subject: [PATCH] Added TreeView and Panel --- .claude/commands/developer.md | 242 +++++++ .claude/commands/reset.md | 13 + .claude/commands/technical-writer.md | 64 ++ .claude/commands/unit-tester.md | 230 ++++++ CLAUDE.md | 30 + Makefile | 1 + src/app.py | 56 +- src/myfasthtml/assets/myfasthtml.css | 208 +++++- src/myfasthtml/assets/myfasthtml.js | 67 +- src/myfasthtml/controls/Dropdown.py | 3 +- src/myfasthtml/controls/InstancesDebugger.py | 12 +- src/myfasthtml/controls/Keyboard.py | 2 +- src/myfasthtml/controls/Layout.py | 9 +- src/myfasthtml/controls/Panel.py | 102 +++ src/myfasthtml/controls/TreeView.py | 400 +++++++++++ src/myfasthtml/controls/helpers.py | 35 +- src/myfasthtml/core/dbmanager.py | 1 + src/myfasthtml/test/matcher.py | 118 ++- tests/controls/conftest.py | 7 +- tests/controls/test_treeview.py | 398 +++++++++++ tests/testclient/test_matches.py | 711 ++++++++++--------- 21 files changed, 2343 insertions(+), 366 deletions(-) create mode 100644 .claude/commands/developer.md create mode 100644 .claude/commands/reset.md create mode 100644 .claude/commands/technical-writer.md create mode 100644 .claude/commands/unit-tester.md create mode 100644 src/myfasthtml/controls/Panel.py create mode 100644 src/myfasthtml/controls/TreeView.py create mode 100644 tests/controls/test_treeview.py diff --git a/.claude/commands/developer.md b/.claude/commands/developer.md new file mode 100644 index 0000000..19b4dc6 --- /dev/null +++ b/.claude/commands/developer.md @@ -0,0 +1,242 @@ +# Developer Mode + +You are now in **Developer Mode** - the standard mode for writing code in the MyFastHtml project. + +## Primary Objective + +Write production-quality code by: + +1. Exploring available options before implementation +2. Validating approach with user +3. Implementing only after approval +4. Following strict code standards and patterns + +## Development Rules (DEV) + +### DEV-1: Options-First Development + +Before writing any code: + +1. **Explain available options first** - Present different approaches to solve the problem +2. **Wait for validation** - Ensure mutual understanding of requirements before implementation +3. **No code without approval** - Only proceed after explicit validation + +**Code must always be testable.** + +### DEV-2: Question-Driven Collaboration + +**Ask questions to clarify understanding or suggest alternative approaches:** + +- Ask questions **one at a time** +- Wait for complete answer before asking the next question +- Indicate progress: "Question 1/5" if multiple questions are needed +- Never assume - always clarify ambiguities + +### DEV-3: Communication Standards + +**Conversations**: French or English (match user's language) +**Code, documentation, comments**: English only + +### DEV-4: Code Standards + +**Follow PEP 8** conventions strictly: + +- Variable and function names: `snake_case` +- Explicit, descriptive naming +- **No emojis in code** + +**Documentation**: + +- Use Google or NumPy docstring format +- Document all public functions and classes +- Include type hints where applicable + +### DEV-5: Dependency Management + +**When introducing new dependencies:** + +- List all external dependencies explicitly +- Propose alternatives using Python standard library when possible +- Explain why each dependency is needed + +### DEV-6: Unit Testing with pytest + +**Test naming patterns:** + +- Passing tests: `test_i_can_xxx` - Tests that should succeed +- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions + +**Test structure:** + +- Use **functions**, not classes (unless inheritance is required) +- Before writing tests, **list all planned tests with explanations** +- Wait for validation before implementing tests + +**Example:** + +```python +def test_i_can_create_command_with_valid_name(): + """Test that a command can be created with a valid name.""" + cmd = Command("valid_name", "description", lambda: None) + assert cmd.name == "valid_name" + + +def test_i_cannot_create_command_with_empty_name(): + """Test that creating a command with empty name raises ValueError.""" + with pytest.raises(ValueError): + Command("", "description", lambda: None) +``` + +### DEV-7: File Management + +**Always specify the full file path** when adding or modifying files: + +``` +✅ Modifying: src/myfasthtml/core/commands.py +✅ Creating: tests/core/test_new_feature.py +``` + +### DEV-8: Command System - HTMX Target-Callback Alignment + +**CRITICAL RULE:** When creating or modifying Commands, the callback's return value MUST match the HTMX configuration. + +**Two-part requirement:** + +1. The HTML structure returned by the callback must correspond to the `target` specified in `.htmx()` +2. Commands must be bound to FastHTML elements using `mk.mk()` or helper shortcuts + +**Important: FastHTML Auto-Rendering** + +- Just return self if you can the whole component to be re-rendered if the class has `__ft__()` method +- FastHTML automatically calls `__ft__()` which returns `render()` for you + +**Binding Commands to Elements** + +Use the `mk` helper from `myfasthtml.controls.helpers`: + +```python +from myfasthtml.controls.helpers import mk + +# Generic binding +mk.mk(element, cmd) + +# Shortcut for buttons +mk.button("Label", command=cmd) + +# Shortcut for icons +mk.icon(icon_svg, command=cmd) + +# Shortcut for clickable labels +mk.label("Label", command=cmd) + +# Shortcut for dialog buttons +mk.dialog_buttons([("OK", cmd_ok), ("Cancel", cmd_cancel)]) +``` + +**Examples:** + +✅ **Correct - Component with __ft__(), returns self:** + +```python +# In Commands class +def toggle_node(self, node_id: str): + return Command( + "ToggleNode", + f"Toggle node {node_id}", + self._owner._toggle_node, # Returns self (not self.render()!) + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + +# In TreeView class +def _toggle_node(self, node_id: str): + """Toggle expand/collapse state of a node.""" + if node_id in self._state.opened: + self._state.opened.remove(node_id) + else: + self._state.opened.append(node_id) + return self # FastHTML calls __ft__() automatically + + +def __ft__(self): + """FastHTML magic method for rendering.""" + return self.render() + + +# In render method - bind command to element +def _render_node(self, node_id: str, level: int = 0): + toggle = mk.mk( + Span("▼" if is_expanded else "▶", cls="mf-treenode-toggle"), + command=self.commands.toggle_node(node_id) + ) +``` + +✅ **Correct - Using shortcuts:** + +```python +# Button with command +button = mk.button("Click me", command=self.commands.do_action()) + +# Icon with command +icon = mk.icon(icon_svg, size=20, command=self.commands.toggle()) + +# Clickable label with command +label = mk.label("Select", command=self.commands.select()) +``` + +❌ **Incorrect - Explicitly calling render():** + +```python +def _toggle_node(self, node_id: str): + # ... + return self.render() # ❌ Don't do this if you have __ft__()! +``` + +❌ **Incorrect - Not binding command to element:** + +```python +# ❌ Command created but not bound to any element +toggle = Span("▼", cls="toggle") # No mk.mk()! +cmd = self.commands.toggle_node(node_id) # Command exists but not used +``` + +**Validation checklist:** + +1. What HTML does the callback return (via `__ft__()` if present)? +2. What is the `target` ID in `.htmx()`? +3. Do they match? +4. Is the command bound to an element using `mk.mk()` or shortcuts? + +**Common patterns:** + +- **Full component re-render**: Callback returns `self` (with `__ft__()`), target is `#{self._id}` +- **Partial update**: Callback returns specific element, target is that element's ID +- **Multiple updates**: Use swap OOB with multiple elements returned + +### DEV-9: Error Handling Protocol + +**When errors occur:** + +1. **Explain the problem clearly first** +2. **Do not propose a fix immediately** +3. **Wait for validation** that the diagnosis is correct +4. Only then propose solutions + +## Managing Rules + +To disable a specific rule, the user can say: + +- "Disable DEV-8" (do not apply the HTMX alignment rule) +- "Enable DEV-8" (re-enable a previously disabled rule) + +When a rule is disabled, acknowledge it and adapt behavior accordingly. + +## Reference + +For detailed architecture and patterns, refer to CLAUDE.md in the project root. + +## Other Personas + +- Use `/technical-writer` to switch to documentation mode +- Use `/unit-tester` to switch unit testing mode +- Use `/reset` to return to default Claude Code mode diff --git a/.claude/commands/reset.md b/.claude/commands/reset.md new file mode 100644 index 0000000..ebcae23 --- /dev/null +++ b/.claude/commands/reset.md @@ -0,0 +1,13 @@ +# Reset to Default Mode + +You are now back to **default Claude Code mode**. + +Follow the standard Claude Code guidelines without any specific persona or specialized behavior. + +Refer to CLAUDE.md for project-specific architecture and patterns. + +## Available Personas + +You can switch to specialized modes: +- `/developer` - Full development mode with validation workflow +- `/technical-writer` - User documentation writing mode diff --git a/.claude/commands/technical-writer.md b/.claude/commands/technical-writer.md new file mode 100644 index 0000000..9d31b37 --- /dev/null +++ b/.claude/commands/technical-writer.md @@ -0,0 +1,64 @@ +# Technical Writer Persona + +You are now acting as a **Technical Writer** specialized in user-facing documentation. + +## Your Role + +Focus on creating and improving **user documentation** for the MyFastHtml library: +- README sections and examples +- Usage guides and tutorials +- Getting started documentation +- Code examples for end users +- API usage documentation (not API reference) + +## What You Don't Handle + +- Docstrings in code (handled by developers) +- Internal architecture documentation +- Code comments +- CLAUDE.md (handled by developers) + +## Documentation Principles + +**Clarity First:** +- Write for developers who are new to MyFastHtml +- Explain the "why" not just the "what" +- Use concrete, runnable examples +- Progressive complexity (simple → advanced) + +**Structure:** +- Start with the problem being solved +- Show minimal working example +- Explain key concepts +- Provide variations and advanced usage +- Link to related documentation + +**Examples Must:** +- Be complete and runnable +- Include necessary imports +- Show expected output when relevant +- Use realistic variable names +- Follow the project's code standards (PEP 8, snake_case, English) + +## Communication Style + +**Conversations:** French or English (match user's language) +**Written documentation:** English only + +## Workflow + +1. **Ask questions** to understand what needs documentation +2. **Propose structure** before writing content +3. **Wait for validation** before proceeding +4. **Write incrementally** - one section at a time +5. **Request feedback** after each section + +## Style Evolution + +The documentation style will improve iteratively based on feedback. Start with clear, simple writing and refine over time. + +## Exiting This Persona + +To return to normal mode: +- Use `/developer` to switch to developer mode +- Use `/reset` to return to default Claude Code mode diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md new file mode 100644 index 0000000..753548a --- /dev/null +++ b/.claude/commands/unit-tester.md @@ -0,0 +1,230 @@ +# Unit Tester Mode + +You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project. + +## Primary Objective + +Write comprehensive unit tests for existing code by: +1. Analyzing the code to understand its behavior +2. Identifying test cases (success paths and edge cases) +3. Proposing test plan for validation +4. Implementing tests only after approval + +## Unit Test Rules (UTR) + +### UTR-1: Test Analysis Before Implementation + +Before writing any tests: +1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` → `tests/foo/test_bar.py`) +2. **Analyze the code thoroughly** - Read and understand the implementation +3. **If tests exist**: Identify what's already covered and what's missing +4. **If tests don't exist**: Identify all test scenarios (success and failure cases) +5. **Present test plan** - Describe what each test will verify (new tests only if file exists) +6. **Wait for validation** - Only proceed after explicit approval + +### UTR-2: Test Naming Conventions + +- **Passing tests**: `test_i_can_xxx` - Tests that should succeed +- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions + +**Example:** +```python +def test_i_can_create_command_with_valid_name(): + """Test that a command can be created with a valid name.""" + cmd = Command("valid_name", "description", lambda: None) + assert cmd.name == "valid_name" + +def test_i_cannot_create_command_with_empty_name(): + """Test that creating a command with empty name raises ValueError.""" + with pytest.raises(ValueError): + Command("", "description", lambda: None) +``` + +### UTR-3: Use Functions, Not Classes (Default) + +- Use **functions** for tests by default +- Only use classes when inheritance or grouping is required (see UTR-10) +- Before writing tests, **list all planned tests with explanations** +- Wait for validation before implementing tests + +### UTR-4: Do NOT Test Python Built-ins + +**Do NOT test Python's built-in functionality.** + +❌ **Bad example - Testing Python list behavior:** +```python +def test_i_can_add_child_to_node(self): + """Test that we can add a child ID to the children list.""" + parent_node = TreeNode(label="Parent", type="folder") + child_id = "child_123" + + parent_node.children.append(child_id) # Just testing list.append() + + assert child_id in parent_node.children # Just testing list membership +``` + +This test validates that Python's `list.append()` works correctly, which is not our responsibility. + +✅ **Good example - Testing business logic:** +```python +def test_i_can_add_child_node(self, root_instance): + """Test adding a child node to a parent.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) # Testing OUR method + + assert child.id in tree_view._state.items # Verify state updated + assert child.id in parent.children # Verify relationship established + assert child.parent == parent.id # Verify bidirectional link +``` + +This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking. + +**Other examples of what NOT to test:** +- Setting/getting attributes: `obj.value = 5; assert obj.value == 5` +- Dictionary operations: `d["key"] = "value"; assert "key" in d` +- String concatenation: `result = "hello" + "world"; assert result == "helloworld"` +- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic) + +### UTR-5: Test Business Logic Only + +**What TO test:** +- Your business logic and algorithms +- Your validation rules +- Your state transformations +- Your integration between components +- Your error handling for invalid inputs +- Your side effects (database updates, command registration, etc.) + +### UTR-6: Test Coverage Requirements + +For each code element, consider testing: + +**Functions/Methods:** +- Valid inputs (typical use cases) +- Edge cases (empty values, None, boundaries) +- Error conditions (invalid inputs, exceptions) +- Return values and side effects + +**Classes:** +- Initialization (default values, custom values) +- State management (attributes, properties) +- Methods (all public methods) +- Integration (interactions with other classes) + +**Components (Controls):** +- Creation and initialization +- State changes +- Commands and their effects +- Rendering (if applicable) +- Edge cases and error conditions + +### UTR-7: Ask Questions One at a Time + +**Ask questions to clarify understanding:** +- Ask questions **one at a time** +- Wait for complete answer before asking the next question +- Indicate progress: "Question 1/5" if multiple questions are needed +- Never assume behavior - always verify understanding + +### UTR-8: Communication Language + +**Conversations**: French or English (match user's language) +**Code, documentation, comments**: English only + +### UTR-9: Code Standards + +**Follow PEP 8** conventions strictly: +- Variable and function names: `snake_case` +- Explicit, descriptive naming +- **No emojis in code** + +**Documentation**: +- Use Google or NumPy docstring format +- Every test should have a clear docstring explaining what it verifies +- Include type hints where applicable + +### UTR-10: Test File Organization + +**File paths:** +- Always specify the full file path when creating test files +- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py` + +**Example:** +``` +✅ Creating: tests/core/test_new_feature.py +✅ Modifying: tests/controls/test_treeview.py +``` + +**Test organization for Controls:** + +Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes: + +```python +class TestControlBehaviour: + """Tests for control behavior and logic.""" + + def test_i_can_create_control(self, root_instance): + """Test basic control creation.""" + control = MyControl(root_instance) + assert control is not None + + def test_i_can_update_state(self, root_instance): + """Test state management.""" + # Test state changes, data updates, etc. + pass + +class TestControlRender: + """Tests for control HTML rendering.""" + + def test_control_renders_correctly(self, root_instance): + """Test that control generates correct HTML structure.""" + # Test HTML output, attributes, classes, etc. + pass + + def test_control_renders_with_custom_config(self, root_instance): + """Test rendering with custom configuration.""" + # Test different rendering scenarios + pass +``` + +**Why separate behaviour and render tests:** +- **Behaviour tests**: Focus on logic, state management, commands, and interactions +- **Render tests**: Focus on HTML structure, attributes, and visual representation +- **Clarity**: Makes it clear what aspect of the control is being tested +- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes + +**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed. + +### UTR-11: Test Workflow + +1. **Receive code to test** - User provides file path or code section +2. **Check existing tests** - Look for corresponding test file and read it if it exists +3. **Analyze code** - Read and understand implementation +4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios +5. **Propose test plan** - List new/missing tests with brief explanations +6. **Wait for approval** - User validates the test plan +7. **Implement tests** - Write all approved tests +8. **Verify** - Ensure tests follow naming conventions and structure +9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests. + +## Managing Rules + +To disable a specific rule, the user can say: +- "Disable UTR-4" (do not apply the rule about testing Python built-ins) +- "Enable UTR-4" (re-enable a previously disabled rule) + +When a rule is disabled, acknowledge it and adapt behavior accordingly. + +## Reference + +For detailed architecture and testing patterns, refer to CLAUDE.md in the project root. + +## Other Personas + +- Use `/developer` to switch to development mode +- Use `/technical-writer` to switch to documentation mode +- Use `/reset` to return to default Claude Code mode diff --git a/CLAUDE.md b/CLAUDE.md index c0e94ba..ad54e2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,36 @@ def test_i_cannot_create_command_with_empty_name(): 3. **Wait for validation** that the diagnosis is correct 4. Only then propose solutions +## Available Personas + +This project includes specialized personas (slash commands) for different types of work: + +### `/developer` - Development Mode (Default) +**Use for:** Writing code, implementing features, fixing bugs + +Activates the full development workflow with: +- Options-first approach before coding +- Step-by-step validation +- Strict PEP 8 compliance +- Test-driven development with `test_i_can_xxx` / `test_i_cannot_xxx` patterns + +### `/technical-writer` - Documentation Mode +**Use for:** Writing user-facing documentation + +Focused on creating: +- README sections and examples +- Usage guides and tutorials +- Getting started documentation +- Code examples for end users + +**Does NOT handle:** +- Docstrings (developer responsibility) +- Internal architecture docs +- CLAUDE.md updates + +### `/reset` - Default Claude Code +**Use for:** Return to standard Claude Code behavior without personas + ## Development Commands ### Testing diff --git a/Makefile b/Makefile index 537c16a..2a6a104 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ clean-tests: rm -rf .sesskey rm -rf tests/.sesskey rm -rf tests/*.db + rm -rf tests/.myFastHtmlDb # Alias to clean everything clean: clean-build clean-tests diff --git a/src/app.py b/src/app.py index 5493a9f..d605152 100644 --- a/src/app.py +++ b/src/app.py @@ -10,9 +10,11 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Layout import Layout from myfasthtml.controls.TabsManager import TabsManager +from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.helpers import Ids, mk from myfasthtml.core.instances import UniqueInstance from myfasthtml.icons.carbon import volume_object_storage +from myfasthtml.icons.fluent_p2 import key_command16_regular from myfasthtml.icons.fluent_p3 import folder_open20_regular from myfasthtml.myfastapp import create_app @@ -31,6 +33,52 @@ app, rt = create_app(protect_routes=True, base_url="http://localhost:5003") +def create_sample_treeview(parent): + """ + Create a sample TreeView with a file structure for testing. + + Args: + parent: Parent instance for the TreeView + + Returns: + TreeView: Configured TreeView instance with sample data + """ + tree_view = TreeView(parent, _id="-treeview") + + # Create sample file structure + projects = TreeNode(label="Projects", type="folder") + tree_view.add_node(projects) + + myfasthtml = TreeNode(label="MyFastHtml", type="folder") + tree_view.add_node(myfasthtml, parent_id=projects.id) + + app_py = TreeNode(label="app.py", type="file") + tree_view.add_node(app_py, parent_id=myfasthtml.id) + + readme = TreeNode(label="README.md", type="file") + tree_view.add_node(readme, parent_id=myfasthtml.id) + + src_folder = TreeNode(label="src", type="folder") + tree_view.add_node(src_folder, parent_id=myfasthtml.id) + + controls_py = TreeNode(label="controls.py", type="file") + tree_view.add_node(controls_py, parent_id=src_folder.id) + + documents = TreeNode(label="Documents", type="folder") + tree_view.add_node(documents, parent_id=projects.id) + + notes = TreeNode(label="notes.txt", type="file") + tree_view.add_node(notes, parent_id=documents.id) + + todo = TreeNode(label="todo.md", type="file") + tree_view.add_node(todo, parent_id=documents.id) + + # Expand all nodes to show the full structure + #tree_view.expand_all() + + return tree_view + + @rt("/") def index(session): session_instance = UniqueInstance(session=session, _id=Ids.UserSession) @@ -51,7 +99,7 @@ def index(session): commands_debugger = CommandsDebugger(layout) btn_show_commands_debugger = mk.label("Commands", - icon=None, + icon=key_command16_regular, command=add_tab("Commands", commands_debugger), id=commands_debugger.get_id()) @@ -62,13 +110,17 @@ def index(session): btn_popup = mk.label("Popup", command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown"))) - + + # Create TreeView with sample data + tree_view = create_sample_treeview(layout) + layout.header_left.add(tabs_manager.add_tab_btn()) layout.header_right.add(btn_show_right_drawer) layout.left_drawer.add(btn_show_instances_debugger, "Debugger") layout.left_drawer.add(btn_show_commands_debugger, "Debugger") layout.left_drawer.add(btn_file_upload, "Test") layout.left_drawer.add(btn_popup, "Test") + layout.left_drawer.add(tree_view, "TreeView") layout.set_main(tabs_manager) keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o", add_tab("File Open", FileUpload(layout, _id="-file_upload"))) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index fed5027..9bfe73f 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -465,4 +465,210 @@ .mf-dropdown.is-visible { display: block; opacity: 1; -} \ No newline at end of file +} + +/* *********************************************** */ +/* ************** TreeView Component ************* */ +/* *********************************************** */ + +/* TreeView Container */ +.mf-treeview { + width: 100%; + user-select: none; +} + +/* TreeNode Container */ +.mf-treenode-container { + width: 100%; +} + +/* TreeNode Element */ +.mf-treenode { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 2px 0.5rem; + cursor: pointer; + transition: background-color 0.15s ease; + border-radius: 0.25rem; +} + +/* Input for Editing */ +.mf-treenode-input { + flex: 1; + padding: 2px 0.25rem; + border: 1px solid var(--color-primary); + border-radius: 0.25rem; + background-color: var(--color-base-100); + outline: none; +} + + +.mf-treenode:hover { + background-color: var(--color-base-200); +} + +.mf-treenode.selected { + background-color: var(--color-primary); + color: var(--color-primary-content); +} + +/* Toggle Icon */ +.mf-treenode-toggle { + flex-shrink: 0; + width: 20px; + text-align: center; + font-size: 0.75rem; +} + +/* Node Label */ +.mf-treenode-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +.mf-treenode-input:focus { + box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 25%, transparent); +} + +/* Action Buttons - Hidden by default, shown on hover */ +.mf-treenode-actions { + display: none; + gap: 0.1rem; + white-space: nowrap; + margin-left: 0.5rem; +} + +.mf-treenode:hover .mf-treenode-actions { + display: flex; +} + +/* *********************************************** */ +/* ********** Generic Resizer Classes ************ */ +/* *********************************************** */ + +/* Generic resizer - used by both Layout and Panel */ +.mf-resizer { + position: absolute; + width: 4px; + cursor: col-resize; + background-color: transparent; + transition: background-color 0.2s ease; + z-index: 100; + top: 0; + bottom: 0; +} + +.mf-resizer:hover { + background-color: rgba(59, 130, 246, 0.3); +} + +/* Active state during resize */ +.mf-resizing .mf-resizer { + background-color: rgba(59, 130, 246, 0.5); +} + +/* Prevent text selection during resize */ +.mf-resizing { + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; +} + +/* Cursor override for entire body during resize */ +.mf-resizing * { + cursor: col-resize !important; +} + +/* Visual indicator for resizer on hover - subtle border */ +.mf-resizer::before { + content: ''; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2px; + height: 40px; + background-color: rgba(156, 163, 175, 0.4); + border-radius: 2px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.mf-resizer:hover::before, +.mf-resizing .mf-resizer::before { + opacity: 1; +} + +/* Resizer positioning */ +/* Left resizer is on the right side of the left panel */ +.mf-resizer-left { + right: 0; +} + +/* Right resizer is on the left side of the right panel */ +.mf-resizer-right { + left: 0; +} + +/* Position indicator for resizer */ +.mf-resizer-left::before { + right: 1px; +} + +.mf-resizer-right::before { + left: 1px; +} + +/* Disable transitions during resize for smooth dragging */ +.mf-item-resizing { + transition: none !important; +} + +/* *********************************************** */ +/* *************** Panel Component *************** */ +/* *********************************************** */ + +/* Container principal du panel */ +.mf-panel { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} + +/* Panel gauche */ +.mf-panel-left { + position: relative; + flex-shrink: 0; + width: 250px; + min-width: 150px; + max-width: 400px; + height: 100%; + overflow: auto; + border-right: 1px solid var(--color-border-primary); +} + +/* Panel principal (centre) */ +.mf-panel-main { + flex: 1; + height: 100%; + overflow: auto; + min-width: 0; /* Important pour permettre le shrink du flexbox */ +} + +/* Panel droit */ +.mf-panel-right { + position: relative; + flex-shrink: 0; + width: 300px; + min-width: 150px; + max-width: 500px; + height: 100%; + overflow: auto; + border-left: 1px solid var(--color-border-primary); +} diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js index d0b0e5f..41715a9 100644 --- a/src/myfasthtml/assets/myfasthtml.js +++ b/src/myfasthtml/assets/myfasthtml.js @@ -1,40 +1,43 @@ /** - * Layout Drawer Resizer + * Generic Resizer * - * Handles resizing of left and right drawers with drag functionality. + * Handles resizing of elements with drag functionality. * Communicates with server via HTMX to persist width changes. + * Works for both Layout drawers and Panel sides. */ /** - * Initialize drawer resizer functionality for a specific layout instance + * Initialize resizer functionality for a specific container * - * @param {string} layoutId - The ID of the layout instance to initialize + * @param {string} containerId - The ID of the container instance to initialize + * @param {Object} options - Configuration options + * @param {number} options.minWidth - Minimum width in pixels (default: 150) + * @param {number} options.maxWidth - Maximum width in pixels (default: 600) */ -function initLayoutResizer(layoutId) { - 'use strict'; +function initResizer(containerId, options = {}) { - const MIN_WIDTH = 150; - const MAX_WIDTH = 600; + const MIN_WIDTH = options.minWidth || 150; + const MAX_WIDTH = options.maxWidth || 600; let isResizing = false; let currentResizer = null; - let currentDrawer = null; + let currentItem = null; let startX = 0; let startWidth = 0; let side = null; - const layoutElement = document.getElementById(layoutId); + const containerElement = document.getElementById(containerId); - if (!layoutElement) { - console.error(`Layout element with ID "${layoutId}" not found`); + if (!containerElement) { + console.error(`Container element with ID "${containerId}" not found`); return; } /** - * Initialize resizer functionality for this layout instance + * Initialize resizer functionality for this container instance */ function initResizers() { - const resizers = layoutElement.querySelectorAll('.mf-layout-resizer'); + const resizers = containerElement.querySelectorAll('.mf-resizer'); resizers.forEach(resizer => { // Remove existing listener if any to avoid duplicates @@ -51,24 +54,24 @@ function initLayoutResizer(layoutId) { currentResizer = e.target; side = currentResizer.dataset.side; - currentDrawer = currentResizer.closest('.mf-layout-drawer'); + currentItem = currentResizer.parentElement; - if (!currentDrawer) { - console.error('Could not find drawer element'); + if (!currentItem) { + console.error('Could not find item element'); return; } isResizing = true; startX = e.clientX; - startWidth = currentDrawer.offsetWidth; + startWidth = currentItem.offsetWidth; // Add event listeners for mouse move and up document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Add resizing class for visual feedback - document.body.classList.add('mf-layout-resizing'); - currentDrawer.classList.add('mf-layout-drawer-resizing'); + document.body.classList.add('mf-resizing'); + currentItem.classList.add('mf-item-resizing'); } /** @@ -92,8 +95,8 @@ function initLayoutResizer(layoutId) { // Constrain width between min and max newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, newWidth)); - // Update drawer width visually - currentDrawer.style.width = `${newWidth}px`; + // Update item width visually + currentItem.style.width = `${newWidth}px`; } /** @@ -109,11 +112,11 @@ function initLayoutResizer(layoutId) { document.removeEventListener('mouseup', handleMouseUp); // Remove resizing classes - document.body.classList.remove('mf-layout-resizing'); - currentDrawer.classList.remove('mf-layout-drawer-resizing'); + document.body.classList.remove('mf-resizing'); + currentItem.classList.remove('mf-item-resizing'); // Get final width - const finalWidth = currentDrawer.offsetWidth; + const finalWidth = currentItem.offsetWidth; const commandId = currentResizer.dataset.commandId; if (!commandId) { @@ -122,24 +125,24 @@ function initLayoutResizer(layoutId) { } // Send width update to server - saveDrawerWidth(commandId, finalWidth); + saveWidth(commandId, finalWidth); // Reset state currentResizer = null; - currentDrawer = null; + currentItem = null; side = null; } /** - * Save drawer width to server via HTMX + * Save width to server via HTMX */ - function saveDrawerWidth(commandId, width) { + function saveWidth(commandId, width) { htmx.ajax('POST', '/myfasthtml/commands', { headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: "outerHTML", - target: `#${currentDrawer.id}`, + target: `#${currentItem.id}`, values: { c_id: commandId, width: width @@ -150,8 +153,8 @@ function initLayoutResizer(layoutId) { // Initialize resizers initResizers(); - // Re-initialize after HTMX swaps within this layout - layoutElement.addEventListener('htmx:afterSwap', function (event) { + // Re-initialize after HTMX swaps within this container + containerElement.addEventListener('htmx:afterSwap', function (event) { initResizers(); }); } diff --git a/src/myfasthtml/controls/Dropdown.py b/src/myfasthtml/controls/Dropdown.py index 076e8a0..20e1695 100644 --- a/src/myfasthtml/controls/Dropdown.py +++ b/src/myfasthtml/controls/Dropdown.py @@ -28,7 +28,6 @@ class Dropdown(MultipleInstance): self.content = content self.commands = Commands(self) self._state = DropdownState() - self._toggle_command = self.commands.toggle() def toggle(self): self._state.opened = not self._state.opened @@ -55,7 +54,7 @@ class Dropdown(MultipleInstance): self._mk_content(), cls="mf-dropdown-wrapper" ), - Keyboard(self, "-keyboard").add("esc", self.commands.close()), + Keyboard(self, _id="-keyboard").add("esc", self.commands.close()), Mouse(self, "-mouse").add("click", self.commands.click()), id=self._id ) diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index fcec443..cda50e1 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -1,3 +1,4 @@ +from myfasthtml.controls.Panel import Panel from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.core.instances import SingleInstance, InstancesManager from myfasthtml.core.network_utils import from_parent_child_list @@ -6,9 +7,14 @@ from myfasthtml.core.network_utils import from_parent_child_list class InstancesDebugger(SingleInstance): def __init__(self, parent, _id=None): super().__init__(parent, _id=_id) + self._panel = Panel(self, _id="-panel") def render(self): - s_name = InstancesManager.get_session_user_name + nodes, edges = self._get_nodes_and_edges() + vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis") + return self._panel.set_main(vis_network) + + def _get_nodes_and_edges(self): instances = self._get_instances() nodes, edges = from_parent_child_list( instances, @@ -23,9 +29,7 @@ class InstancesDebugger(SingleInstance): for node in nodes: node["shape"] = "box" - vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis") - # vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}}) - return vis_network + return nodes, edges def _get_instances(self): return list(InstancesManager.instances.values()) diff --git a/src/myfasthtml/controls/Keyboard.py b/src/myfasthtml/controls/Keyboard.py index cc333d9..f3c2d77 100644 --- a/src/myfasthtml/controls/Keyboard.py +++ b/src/myfasthtml/controls/Keyboard.py @@ -7,7 +7,7 @@ from myfasthtml.core.instances import MultipleInstance class Keyboard(MultipleInstance): - def __init__(self, parent, _id=None, combinations=None): + def __init__(self, parent, combinations=None, _id=None): super().__init__(parent, _id=_id) self.combinations = combinations or {} diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 264329f..0c00d0a 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -123,6 +123,7 @@ class Layout(SingleInstance): self.header_right = self.Content(self) self.footer_left = self.Content(self) self.footer_right = self.Content(self) + self._footer_content = None def set_footer(self, content): """ @@ -141,6 +142,7 @@ class Layout(SingleInstance): content: FastHTML component(s) or content for the main area """ self._main_content = content + return self def toggle_drawer(self, side: Literal["left", "right"]): logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}") @@ -233,7 +235,7 @@ class Layout(SingleInstance): Div: FastHTML Div component for left drawer """ resizer = Div( - cls="mf-layout-resizer mf-layout-resizer-right", + cls="mf-resizer mf-resizer-left", data_command_id=self.commands.update_drawer_width("left").id, data_side="left" ) @@ -266,8 +268,9 @@ class Layout(SingleInstance): Returns: Div: FastHTML Div component for right drawer """ + resizer = Div( - cls="mf-layout-resizer mf-layout-resizer-left", + cls="mf-resizer mf-resizer-right", data_command_id=self.commands.update_drawer_width("right").id, data_side="right" ) @@ -311,7 +314,7 @@ class Layout(SingleInstance): self._mk_main(), self._mk_right_drawer(), self._mk_footer(), - Script(f"initLayoutResizer('{self._id}');"), + Script(f"initResizer('{self._id}');"), id=self._id, cls="mf-layout", ) diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py new file mode 100644 index 0000000..66a2bbf --- /dev/null +++ b/src/myfasthtml/controls/Panel.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass +from typing import Literal + +from fasthtml.components import Div +from fasthtml.xtend import Script + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.core.commands import Command +from myfasthtml.core.instances import MultipleInstance + + +@dataclass +class PanelConf: + left: bool = False + right: bool = True + + +class Commands(BaseCommands): + def toggle_side(self, side: Literal["left", "right"]): + return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side) + + def update_side_width(self, side: Literal["left", "right"]): + """ + Create a command to update panel's side width. + + Args: + side: Which panel's side to update ("left" or "right") + + Returns: + Command: Command object for updating panel's side width + """ + return Command( + f"UpdatePanelSideWidth_{side}", + f"Update {side} side panel width", + self._owner.update_side_width, + side + ) + + +class Panel(MultipleInstance): + def __init__(self, parent, conf=None, _id=None): + super().__init__(parent, _id=_id) + self.conf = conf or PanelConf() + self.commands = Commands(self) + self._main = None + self._right = None + self._left = None + + def update_side_width(self, side, width): + pass + + def toggle_side(self, side): + pass + + def set_main(self, main): + self._main = main + return self + + def set_right(self, right): + self._right = right + return self + + def set_left(self, left): + self._left = left + return self + + def _mk_right(self): + if not self.conf.right: + return None + + resizer = Div( + cls="mf-resizer mf-resizer-right", + data_command_id=self.commands.update_side_width("right").id, + data_side="right" + ) + + return Div(resizer, self._right, cls="mf-panel-right") + + def _mk_left(self): + if not self.conf.left: + return None + + resizer = Div( + cls="mf-resizer mf-resizer-left", + data_command_id=self.commands.update_side_width("left").id, + data_side="left" + ) + + return Div(self._left, resizer, cls="mf-panel-left") + + def render(self): + return Div( + self._mk_left(), + Div(self._main, cls="mf-panel-main"), + self._mk_right(), + Script(f"initResizer('{self._id}');"), + cls="mf-panel", + id=self._id, + ) + + def __ft__(self): + return self.render() diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py new file mode 100644 index 0000000..cd0442b --- /dev/null +++ b/src/myfasthtml/controls/TreeView.py @@ -0,0 +1,400 @@ +""" +TreeView component for hierarchical data visualization with inline editing. + +This component provides an interactive tree structure with expand/collapse, +selection, and inline editing capabilities. +""" +import uuid +from dataclasses import dataclass, field +from typing import Optional + +from fasthtml.components import Div, Input, Span + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.Keyboard import Keyboard +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.instances import MultipleInstance +from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular +from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular + + +@dataclass +class TreeNode: + """ + Represents a node in the tree structure. + + Attributes: + id: Unique identifier (auto-generated UUID if not provided) + label: Display text for the node + type: Node type for icon mapping + parent: ID of parent node (None for root) + children: List of child node IDs + """ + id: str = field(default_factory=lambda: str(uuid.uuid4())) + label: str = "" + type: str = "default" + parent: Optional[str] = None + children: list[str] = field(default_factory=list) + + +class TreeViewState(DbObject): + """ + Persistent state for TreeView component. + + Attributes: + items: Dictionary mapping node IDs to TreeNode instances + opened: List of expanded node IDs + selected: Currently selected node ID + editing: Node ID currently being edited (None if not editing) + icon_config: Mapping of node types to icon identifiers + """ + + def __init__(self, owner): + super().__init__(owner) + with self.initializing(): + self.items: dict[str, TreeNode] = {} + self.opened: list[str] = [] + self.selected: Optional[str] = None + self.editing: Optional[str] = None + self.icon_config: dict[str, str] = {} + + +class Commands(BaseCommands): + """Command handlers for TreeView actions.""" + + def toggle_node(self, node_id: str): + """Create command to expand/collapse a node.""" + return Command( + "ToggleNode", + f"Toggle node {node_id}", + self._owner._toggle_node, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + def add_child(self, parent_id: str): + """Create command to add a child node.""" + return Command( + "AddChild", + f"Add child to {parent_id}", + self._owner._add_child, + parent_id + ).htmx(target=f"#{self._owner.get_id()}") + + def add_sibling(self, node_id: str): + """Create command to add a sibling node.""" + return Command( + "AddSibling", + f"Add sibling to {node_id}", + self._owner._add_sibling, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + def start_rename(self, node_id: str): + """Create command to start renaming a node.""" + return Command( + "StartRename", + f"Start renaming {node_id}", + self._owner._start_rename, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + def save_rename(self, node_id: str): + """Create command to save renamed node.""" + return Command( + "SaveRename", + f"Save rename for {node_id}", + self._owner._save_rename, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + def cancel_rename(self): + """Create command to cancel renaming.""" + return Command( + "CancelRename", + "Cancel rename", + self._owner._cancel_rename + ).htmx(target=f"#{self._owner.get_id()}") + + def delete_node(self, node_id: str): + """Create command to delete a node.""" + return Command( + "DeleteNode", + f"Delete node {node_id}", + self._owner._delete_node, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + def select_node(self, node_id: str): + """Create command to select a node.""" + return Command( + "SelectNode", + f"Select node {node_id}", + self._owner._select_node, + node_id + ).htmx(target=f"#{self._owner.get_id()}") + + +class TreeView(MultipleInstance): + """ + Interactive TreeView component with hierarchical data visualization. + + Supports: + - Expand/collapse nodes + - Add child/sibling nodes + - Inline rename + - Delete nodes + - Node selection + """ + + def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None): + """ + Initialize TreeView component. + + Args: + parent: Parent instance + items: Optional initial items dictionary {node_id: TreeNode} + _id: Optional custom ID + """ + super().__init__(parent, _id=_id) + self._state = TreeViewState(self) + self.commands = Commands(self) + + if items: + self._state.items = items + + def set_icon_config(self, config: dict[str, str]): + """ + Set icon configuration for node types. + + Args: + config: Dictionary mapping node types to icon identifiers + Format: {type: "provider.icon_name"} + """ + self._state.icon_config = config + + def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None): + """ + Add a node to the tree. + + Args: + node: TreeNode instance to add + parent_id: Optional parent node ID (None for root) + insert_index: Optional index to insert at in parent's children list. + If None, appends to end. If provided, inserts at that position. + """ + self._state.items[node.id] = node + node.parent = parent_id + + if parent_id and parent_id in self._state.items: + parent = self._state.items[parent_id] + if node.id not in parent.children: + if insert_index is not None: + parent.children.insert(insert_index, node.id) + else: + parent.children.append(node.id) + + def expand_all(self): + """Expand all nodes that have children.""" + for node_id, node in self._state.items.items(): + if node.children and node_id not in self._state.opened: + self._state.opened.append(node_id) + + def _toggle_node(self, node_id: str): + """Toggle expand/collapse state of a node.""" + if node_id in self._state.opened: + self._state.opened.remove(node_id) + else: + self._state.opened.append(node_id) + return self + + def _add_child(self, parent_id: str, new_label: Optional[str] = None): + """Add a child node to a parent.""" + if parent_id not in self._state.items: + raise ValueError(f"Parent node {parent_id} does not exist") + + parent = self._state.items[parent_id] + new_node = TreeNode( + label=new_label or "New Node", + type=parent.type + ) + + self.add_node(new_node, parent_id=parent_id) + + # Auto-expand parent + if parent_id not in self._state.opened: + self._state.opened.append(parent_id) + + return self + + def _add_sibling(self, node_id: str, new_label: Optional[str] = None): + """Add a sibling node next to a node.""" + if node_id not in self._state.items: + raise ValueError(f"Node {node_id} does not exist") + + node = self._state.items[node_id] + + if node.parent is None: + raise ValueError("Cannot add sibling to root node") + + parent = self._state.items[node.parent] + new_node = TreeNode( + label=new_label or "New Node", + type=node.type + ) + + # Insert after current node + insert_idx = parent.children.index(node_id) + 1 + self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx) + + return self + + def _start_rename(self, node_id: str): + """Start renaming a node (sets editing state).""" + if node_id not in self._state.items: + raise ValueError(f"Node {node_id} does not exist") + + self._state.editing = node_id + return self + + def _save_rename(self, node_id: str, node_label: str): + """Save renamed node with new label.""" + if node_id not in self._state.items: + raise ValueError(f"Node {node_id} does not exist") + + self._state.items[node_id].label = node_label + self._state.editing = None + return self + + def _cancel_rename(self): + """Cancel renaming operation.""" + self._state.editing = None + return self + + def _delete_node(self, node_id: str): + """Delete a node (only if it has no children).""" + if node_id not in self._state.items: + raise ValueError(f"Node {node_id} does not exist") + + node = self._state.items[node_id] + + if node.children: + raise ValueError(f"Cannot delete node {node_id} with children") + + # Remove from parent's children list + if node.parent and node.parent in self._state.items: + parent = self._state.items[node.parent] + parent.children.remove(node_id) + + # Remove from state + del self._state.items[node_id] + + if node_id in self._state.opened: + self._state.opened.remove(node_id) + + if self._state.selected == node_id: + self._state.selected = None + + return self + + def _select_node(self, node_id: str): + """Select a node.""" + if node_id not in self._state.items: + raise ValueError(f"Node {node_id} does not exist") + + self._state.selected = node_id + return self + + def _render_action_buttons(self, node_id: str): + """Render action buttons for a node (visible on hover).""" + return Div( + mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)), + mk.icon(edit20_regular, command=self.commands.start_rename(node_id)), + mk.icon(delete20_regular, command=self.commands.delete_node(node_id)), + cls="mf-treenode-actions" + ) + + def _render_node(self, node_id: str, level: int = 0): + """ + Render a single node and its children recursively. + + Args: + node_id: ID of node to render + level: Indentation level + + Returns: + Div containing the node and its children + """ + node = self._state.items[node_id] + is_expanded = node_id in self._state.opened + is_selected = node_id == self._state.selected + is_editing = node_id == self._state.editing + has_children = len(node.children) > 0 + + # Toggle icon + toggle = mk.icon( + chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ", + command=self.commands.toggle_node(node_id)) + + # Label or input for editing + if is_editing: + # TODO: Bind input to save_rename (Enter) and cancel_rename (Escape) + label_element = mk.mk(Input( + name="node_label", + value=node.label, + cls="mf-treenode-input input input-sm" + ), command=self.commands.save_rename(node_id)) + else: + label_element = mk.mk( + Span(node.label, cls="mf-treenode-label text-sm"), + command=self.commands.select_node(node_id) + ) + + # Node element + node_element = Div( + toggle, + label_element, + self._render_action_buttons(node_id), + cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}", + data_node_id=node_id, + style=f"padding-left: {level * 20}px" + ) + + # Children (if expanded) + children_elements = [] + if is_expanded and has_children: + for child_id in node.children: + children_elements.append( + self._render_node(child_id, level + 1) + ) + + return Div( + node_element, + *children_elements, + cls="mf-treenode-container" + ) + + def render(self): + """ + Render the complete TreeView. + + Returns: + Div: Complete TreeView HTML structure + """ + # Find root nodes (nodes without parent) + root_nodes = [ + node_id for node_id, node in self._state.items.items() + if node.parent is None + ] + + return Div( + *[self._render_node(node_id) for node_id in root_nodes], + Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"), + id=self._id, + cls="mf-treeview" + ) + + def __ft__(self): + """FastHTML magic method for rendering.""" + return self.render() diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index ddebac1..4c19abb 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -15,6 +15,19 @@ class mk: @staticmethod def button(element, command: Command = None, binding: Binding = None, **kwargs): + """ + Defines a static method for creating a Button object with specific configurations. + + This method constructs a Button instance by wrapping an element with + additional configurations such as commands and bindings. Any extra keyword + arguments are passed when creating the Button. + + :param element: The underlying widget or element to be wrapped in a Button. + :param command: An optional command to associate with the Button. Defaults to None. + :param binding: An optional event binding to associate with the Button. Defaults to None. + :param kwargs: Additional keyword arguments to further configure the Button. + :return: A fully constructed Button instance with the specified configurations. + """ return mk.mk(Button(element, **kwargs), command=command, binding=binding) @staticmethod @@ -33,13 +46,33 @@ class mk: ) @staticmethod - def icon(icon, size=20, + def icon(icon, + size=20, can_select=True, can_hover=False, cls='', command: Command = None, binding: Binding = None, **kwargs): + """ + Generates an icon element with customizable properties for size, class, and interactivity. + + This method creates an icon element wrapped in a container with optional classes + and event bindings. The icon can be styled and its behavior defined using the parameters + provided, allowing for dynamic and reusable UI components. + + :param icon: The icon to display inside the container. + :param size: The size of the icon, specified in pixels. Defaults to 20. + :param can_select: Indicates whether the icon can be selected. Defaults to True. + :param can_hover: Indicates whether the icon reacts to hovering. Defaults to False. + :param cls: A string of custom CSS classes to be added to the icon container. + :param command: The command object defining the function to be executed on icon interaction. + :param binding: The binding object for configuring additional event listeners on the icon. + :param kwargs: Additional keyword arguments for configuring attributes and behaviors of the + icon element. + :return: A styled and interactive icon element embedded inside a container, configured + with the defined classes, size, and behaviors. + """ merged_cls = merge_classes(f"mf-icon-{size}", 'icon-btn' if can_select else '', 'mmt-btn' if can_hover else '', diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index ddf05e7..16756fe 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -10,6 +10,7 @@ from myfasthtml.core.utils import retrieve_user_info class DbManager(SingleInstance): def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True): super().__init__(parent, auto_register=auto_register) + self.db = DbEngine(root=root) def save(self, entry, obj): diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index 26577ee..5b313cb 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -6,6 +6,8 @@ from fastcore.basics import NotStr from myfasthtml.core.utils import quoted_str from myfasthtml.test.testclient import MyFT +MISSING_ATTR = "** MISSING **" + class Predicate: def __init__(self, value): @@ -114,6 +116,18 @@ class AttributeForbidden(ChildrenPredicate): return element +class TestObject: + def __init__(self, cls, **kwargs): + self.cls = cls + self.attrs = kwargs + + +class TestCommand(TestObject): + def __init__(self, name, **kwargs): + super().__init__("Command", **kwargs) + self.attrs = {"name": name} | kwargs # name should be first + + @dataclass class DoNotCheck: desc: str = None @@ -187,6 +201,16 @@ class ErrorOutput: self.indent = self.indent[:-2] self._add_to_output(")") + + elif isinstance(self.expected, TestObject): + cls = _mytype(self.element) + attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs} + self._add_to_output(f"({cls} {_str_attrs(attrs)})") + # Try to show where the differences are + error_str = self._detect_error_2(self.element, self.expected) + if error_str: + self._add_to_output(error_str) + else: self._add_to_output(str(self.element)) # Try to show where the differences are @@ -205,7 +229,7 @@ class ErrorOutput: if hasattr(element, "tag"): # the attributes are compared to the expected element - elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in + elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in [attr_name for attr_name in expected.attrs if attr_name is not None]} elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) @@ -228,7 +252,7 @@ class ErrorOutput: def _detect_error(self, element, expected): if hasattr(expected, "tag") and hasattr(element, "tag"): tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^") - elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs} + elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs} attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if not self._matches(attr_value, expected.attrs[attr_name])] if attrs_in_error: @@ -242,6 +266,35 @@ class ErrorOutput: return None + def _detect_error_2(self, element, expected): + """ + Too lazy to refactor original _detect_error + :param element: + :param expected: + :return: + """ + if hasattr(expected, "tag") or isinstance(expected, TestObject): + element_cls = _mytype(element) + expected_cls = _mytype(expected) + str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls) + + element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs} + expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs} + attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if + not self._matches(attr_value, expected_attrs[attr_name])} + str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") + for name, value in element_attrs.items()) + if str_attrs_error.strip() or str_tag_error.strip(): + return f" {str_tag_error} {str_attrs_error}" + else: + return None + + else: + if not self._matches(element, expected): + return len(str(element)) * "^" + + return None + @staticmethod def _matches(element, expected): if element == expected: @@ -347,6 +400,34 @@ def matches(actual, expected, path=""): # set the path path += "." + _get_current_path(actual) if path else _get_current_path(actual) + if isinstance(expected, TestObject): + assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ", + _actual=actual, + _expected=expected) + + for attr, value in expected.attrs.items(): + assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.", + _actual=actual, + _expected=expected) + try: + matches(getattr(actual, attr), value) + except AssertionError as e: + match = re.search(r"Error : (.+?)\n", str(e)) + if match: + assert False, _error_msg(f"{match.group(1)} for '{attr}':", + _actual=getattr(actual, attr), + _expected=value) + assert False, _error_msg(f"The values are different for '{attr}': ", + _actual=getattr(actual, attr), + _expected=value) + return True + + if isinstance(expected, Predicate): + assert expected.validate(actual), \ + _error_msg(f"The condition '{expected}' is not satisfied.", + _actual=actual, + _expected=expected) + assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \ _error_msg("The types are different: ", _actual=actual, _expected=expected) @@ -359,6 +440,14 @@ def matches(actual, expected, path=""): for actual_child, expected_child in zip(actual, expected): assert matches(actual_child, expected_child, path=path) + elif isinstance(expected, dict): + if len(actual) < len(expected): + _assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected) + if len(actual) > len(expected): + _assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected) + for k, v in expected.items(): + assert matches(actual[k], v, path=f"{path}[{k}={v}]") + elif isinstance(expected, NotStr): to_compare = actual.s.lstrip('\n').lstrip() assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ", @@ -404,8 +493,9 @@ def matches(actual, expected, path=""): for actual_child, expected_child in zip(actual.children, expected_children): assert matches(actual_child, expected_child, path=path) + else: - assert actual == expected, _error_msg("The values are different: ", + assert actual == expected, _error_msg("The values are different", _actual=actual, _expected=expected) @@ -466,3 +556,25 @@ def find(ft, expected): raise AssertionError(f"No element found for '{expected}'") return res + + +def _mytype(x): + if hasattr(x, "tag"): + return x.tag + if isinstance(x, TestObject): + return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls) + return type(x).__name__ + + +def _mygetattr(x, attr): + if hasattr(x, "attrs"): + return x.attrs.get(attr, MISSING_ATTR) + + if not hasattr(x, attr): + return MISSING_ATTR + + return getattr(x, attr, MISSING_ATTR) + + +def _str_attrs(attrs: dict): + return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items()) diff --git a/tests/controls/conftest.py b/tests/controls/conftest.py index aa34823..fd1170e 100644 --- a/tests/controls/conftest.py +++ b/tests/controls/conftest.py @@ -3,6 +3,11 @@ import pytest from myfasthtml.core.instances import SingleInstance +class RootInstanceForTests(SingleInstance): + def __init__(self, session): + super().__init__(None, session, _id="TestRoot") + + @pytest.fixture(scope="session") def session(): return { @@ -20,4 +25,4 @@ def session(): @pytest.fixture(scope="session") def root_instance(session): - return SingleInstance(None, session, "TestRoot") + return RootInstanceForTests(session=session) diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py new file mode 100644 index 0000000..3dadd81 --- /dev/null +++ b/tests/controls/test_treeview.py @@ -0,0 +1,398 @@ +"""Unit tests for TreeView component.""" +import shutil + +import pytest +from fasthtml.components import * + +from myfasthtml.controls.Keyboard import Keyboard +from myfasthtml.controls.TreeView import TreeView, TreeNode +from myfasthtml.test.matcher import matches, TestObject, TestCommand +from .conftest import root_instance + + +@pytest.fixture(autouse=True) +def cleanup_db(): + shutil.rmtree(".myFastHtmlDb", ignore_errors=True) + + +class TestTreeviewBehaviour: + """Tests for TreeView behavior and logic.""" + + def test_i_can_create_tree_node_with_auto_generated_id(self): + """Test that TreeNode generates UUID automatically.""" + node = TreeNode(label="Test Node", type="folder") + + assert node.id is not None + assert isinstance(node.id, str) + assert len(node.id) > 0 + + def test_i_can_create_tree_node_with_default_values(self): + """Test that TreeNode has correct default values.""" + node = TreeNode() + + assert node.label == "" + assert node.type == "default" + assert node.parent is None + assert node.children == [] + + def test_i_can_initialize_tree_view_state(self, root_instance): + """Test that TreeViewState initializes with default values.""" + tree_view = TreeView(root_instance) + state = tree_view._state + + assert isinstance(state.items, dict) + assert len(state.items) == 0 + assert state.opened == [] + assert state.selected is None + assert state.editing is None + assert state.icon_config == {} + + def test_i_can_create_empty_treeview(self, root_instance): + """Test creating an empty TreeView.""" + tree_view = TreeView(root_instance) + + assert tree_view is not None + assert len(tree_view._state.items) == 0 + + def test_i_can_add_node_to_treeview(self, root_instance): + """Test adding a root node to the TreeView.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Root Node", type="folder") + + tree_view.add_node(node) + + assert node.id in tree_view._state.items + assert tree_view._state.items[node.id].label == "Root Node" + assert tree_view._state.items[node.id].parent is None + + def test_i_can_add_child_node(self, root_instance): + """Test adding a child node to a parent.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + assert child.id in tree_view._state.items + assert child.id in parent.children + assert child.parent == parent.id + + def test_i_can_set_icon_config(self, root_instance): + """Test setting icon configuration.""" + tree_view = TreeView(root_instance) + config = { + "folder": "fluent.folder", + "file": "fluent.document" + } + + tree_view.set_icon_config(config) + + assert tree_view._state.icon_config == config + assert tree_view._state.icon_config["folder"] == "fluent.folder" + + def test_i_can_toggle_node(self, root_instance): + """Test expand/collapse a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Node", type="folder") + tree_view.add_node(node) + + # Initially closed + assert node.id not in tree_view._state.opened + + # Toggle to open + tree_view._toggle_node(node.id) + assert node.id in tree_view._state.opened + + # Toggle to close + tree_view._toggle_node(node.id) + assert node.id not in tree_view._state.opened + + def test_i_can_expand_all_nodes(self, root_instance): + """Test that expand_all opens all nodes with children.""" + tree_view = TreeView(root_instance) + + # Create hierarchy: root -> child1 -> grandchild + # -> child2 (leaf) + root = TreeNode(label="Root", type="folder") + child1 = TreeNode(label="Child 1", type="folder") + grandchild = TreeNode(label="Grandchild", type="file") + child2 = TreeNode(label="Child 2", type="file") + + tree_view.add_node(root) + tree_view.add_node(child1, parent_id=root.id) + tree_view.add_node(grandchild, parent_id=child1.id) + tree_view.add_node(child2, parent_id=root.id) + + # Initially all closed + assert len(tree_view._state.opened) == 0 + + # Expand all + tree_view.expand_all() + + # Nodes with children should be opened + assert root.id in tree_view._state.opened + assert child1.id in tree_view._state.opened + + # Leaf nodes should not be in opened list + assert grandchild.id not in tree_view._state.opened + assert child2.id not in tree_view._state.opened + + def test_i_can_select_node(self, root_instance): + """Test selecting a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Node", type="folder") + tree_view.add_node(node) + + tree_view._select_node(node.id) + + assert tree_view._state.selected == node.id + + def test_i_can_start_rename_node(self, root_instance): + """Test starting rename mode for a node.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Old Name", type="folder") + tree_view.add_node(node) + + tree_view._start_rename(node.id) + + assert tree_view._state.editing == node.id + + def test_i_can_save_rename_node(self, root_instance): + """Test saving renamed node with new label.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Old Name", type="folder") + tree_view.add_node(node) + tree_view._start_rename(node.id) + + tree_view._save_rename(node.id, "New Name") + + assert tree_view._state.items[node.id].label == "New Name" + assert tree_view._state.editing is None + + def test_i_can_cancel_rename_node(self, root_instance): + """Test canceling rename operation.""" + tree_view = TreeView(root_instance) + node = TreeNode(label="Name", type="folder") + tree_view.add_node(node) + tree_view._start_rename(node.id) + + tree_view._cancel_rename() + + assert tree_view._state.editing is None + assert tree_view._state.items[node.id].label == "Name" + + def test_i_can_delete_leaf_node(self, root_instance): + """Test deleting a node without children.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Delete child (leaf node) + tree_view._delete_node(child.id) + + assert child.id not in tree_view._state.items + assert child.id not in parent.children + + def test_i_can_add_sibling_node(self, root_instance): + """Test adding a sibling node.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child1 = TreeNode(label="Child 1", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child1, parent_id=parent.id) + + # Add sibling to child1 + tree_view._add_sibling(child1.id, new_label="Child 2") + + assert len(parent.children) == 2 + # Sibling should be after child1 + assert parent.children.index(child1.id) < len(parent.children) - 1 + + def test_i_cannot_delete_node_with_children(self, root_instance): + """Test that deleting a node with children raises an error.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Try to delete parent (has children) + with pytest.raises(ValueError, match="Cannot delete node.*with children"): + tree_view._delete_node(parent.id) + + def test_i_cannot_add_sibling_to_root(self, root_instance): + """Test that adding sibling to root node raises an error.""" + tree_view = TreeView(root_instance) + root = TreeNode(label="Root", type="folder") + tree_view.add_node(root) + + # Try to add sibling to root (no parent) + with pytest.raises(ValueError, match="Cannot add sibling to root node"): + tree_view._add_sibling(root.id) + + def test_i_cannot_select_nonexistent_node(self, root_instance): + """Test that selecting a nonexistent node raises an error.""" + tree_view = TreeView(root_instance) + + # Try to select node that doesn't exist + with pytest.raises(ValueError, match="Node.*does not exist"): + tree_view._select_node("nonexistent_id") + + def test_add_node_prevents_duplicate_children(self, root_instance): + """Test that add_node prevents adding duplicate child IDs.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Try to add the same child again + tree_view.add_node(child, parent_id=parent.id) + + # Child should appear only once in parent's children list + assert parent.children.count(child.id) == 1 + + def test_sibling_is_inserted_at_correct_position(self, root_instance): + """Test that _add_sibling inserts sibling exactly after reference node.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child1 = TreeNode(label="Child 1", type="file") + child3 = TreeNode(label="Child 3", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child1, parent_id=parent.id) + tree_view.add_node(child3, parent_id=parent.id) + + # Add sibling after child1 + tree_view._add_sibling(child1.id, new_label="Child 2") + + # Get the newly added sibling + sibling_id = parent.children[1] + + # Verify order: child1, sibling (child2), child3 + assert parent.children[0] == child1.id + assert tree_view._state.items[sibling_id].label == "Child 2" + assert parent.children[2] == child3.id + assert len(parent.children) == 3 + + def test_add_child_auto_expands_parent(self, root_instance): + """Test that _add_child automatically expands the parent node.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + + tree_view.add_node(parent) + + # Parent should not be expanded initially + assert parent.id not in tree_view._state.opened + + # Add child + tree_view._add_child(parent.id, new_label="Child") + + # Parent should now be expanded + assert parent.id in tree_view._state.opened + + def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance): + """Test that adding child to nonexistent parent raises error.""" + tree_view = TreeView(root_instance) + + # Try to add child to parent that doesn't exist + with pytest.raises(ValueError, match="Parent node.*does not exist"): + tree_view._add_child("nonexistent_parent_id") + + def test_delete_node_clears_selection_if_selected(self, root_instance): + """Test that deleting a selected node clears the selection.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Select the child + tree_view._select_node(child.id) + assert tree_view._state.selected == child.id + + # Delete the selected child + tree_view._delete_node(child.id) + + # Selection should be cleared + assert tree_view._state.selected is None + + def test_delete_node_removes_from_opened_if_expanded(self, root_instance): + """Test that deleting an expanded node removes it from opened list.""" + tree_view = TreeView(root_instance) + parent = TreeNode(label="Parent", type="folder") + child = TreeNode(label="Child", type="file") + + tree_view.add_node(parent) + tree_view.add_node(child, parent_id=parent.id) + + # Expand the parent + tree_view._toggle_node(parent.id) + assert parent.id in tree_view._state.opened + + # Delete the child (making parent a leaf) + tree_view._delete_node(child.id) + + # Now delete the parent (now a leaf node) + # First remove it from root by creating a grandparent + grandparent = TreeNode(label="Grandparent", type="folder") + tree_view.add_node(grandparent) + parent.parent = grandparent.id + grandparent.children.append(parent.id) + + tree_view._delete_node(parent.id) + + # Parent should be removed from opened list + assert parent.id not in tree_view._state.opened + + def test_i_cannot_start_rename_nonexistent_node(self, root_instance): + """Test that starting rename on nonexistent node raises error.""" + tree_view = TreeView(root_instance) + + # Try to start rename on node that doesn't exist + with pytest.raises(ValueError, match="Node.*does not exist"): + tree_view._start_rename("nonexistent_id") + + def test_i_cannot_save_rename_nonexistent_node(self, root_instance): + """Test that saving rename for nonexistent node raises error.""" + tree_view = TreeView(root_instance) + + # Try to save rename for node that doesn't exist + with pytest.raises(ValueError, match="Node.*does not exist"): + tree_view._save_rename("nonexistent_id", "New Name") + + def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance): + """Test that adding sibling to nonexistent node raises error.""" + tree_view = TreeView(root_instance) + + # Try to add sibling to node that doesn't exist + with pytest.raises(ValueError, match="Node.*does not exist"): + tree_view._add_sibling("nonexistent_id") + + +class TestTreeViewRender: + """Tests for TreeView HTML rendering.""" + + def test_i_can_render_empty_treeview(self, root_instance): + """Test that TreeView generates correct HTML structure.""" + tree_view = TreeView(root_instance) + expected = Div( + TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}), + _id=tree_view.get_id(), + cls="mf-treeview" + ) + + assert matches(tree_view.__ft__(), expected) + + def test_node_action_buttons_are_rendered(self): + """Test that action buttons are present in rendered HTML.""" + # Signature only - implementation later + pass diff --git a/tests/testclient/test_matches.py b/tests/testclient/test_matches.py index 49acebd..b24fdf5 100644 --- a/tests/testclient/test_matches.py +++ b/tests/testclient/test_matches.py @@ -3,361 +3,440 @@ from fastcore.basics import NotStr from fasthtml.components import * from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ - ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren + ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject from myfasthtml.test.testclient import MyFT -@pytest.mark.parametrize('actual, expected', [ - (None, None), - (123, 123), - (Div(), Div()), - ([Div(), Span()], [Div(), Span()]), - (Div(attr1="value"), Div(attr1="value")), - (Div(attr1="value", attr2="value"), Div(attr1="value")), - (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), - (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), - (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), - (Div(attr1="value"), Div(attr1=AnyValue())), - (None, DoNotCheck()), - (123, DoNotCheck()), - (Div(), DoNotCheck()), - ([Div(), Span()], DoNotCheck()), - (NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked - (Div(), Div(Empty())), - (Div(), Div(NoChildren())), - (Div(attr1="value"), Div(NoChildren())), - (Div(attr1="value1"), Div(AttributeForbidden("attr2"))), - (Div(123), Div(123)), - (Div(Span(123)), Div(Span(123))), - (Div(Span(123)), Div(DoNotCheck())), -]) -def test_i_can_match(actual, expected): - assert matches(actual, expected) +class Dummy: + def __init__(self, attr1, attr2=None): + self.attr1 = attr1 + self.attr2 = attr2 -@pytest.mark.parametrize('actual, expected, error_message', [ - (None, Div(), "Actual is None"), - (Div(), None, "Actual is not None"), - (123, Div(), "The types are different"), - (123, 124, "The values are different"), - ([Div(), Span()], [], "Actual is bigger than expected"), - ([], [Div(), Span()], "Actual is smaller than expected"), - ("not a list", [Div(), Span()], "The types are different"), - ([Div(), Span()], [Div(), 123], "The types are different"), - (Div(), Span(), "The elements are different"), - ([Div(), Span()], [Div(), Div()], "The elements are different"), - (Div(), Div(attr1="value"), "'attr1' is not found in Actual"), - (Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"), - (Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"), - (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), - (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), - (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), - (Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), - (Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), - (NotStr("456"), NotStr("123"), "Notstr values are different"), - (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"), - (Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"), - (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), - (Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"), - (Div(), Div(Span()), "Actual is lesser than expected"), - (Div(), Div(123), "Actual is lesser than expected"), - (Div(Span()), Div(Div()), "The elements are different"), - (Div(123), Div(Div()), "The types are different"), - (Div(123), Div(456), "The values are different"), - (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"), - (Div(Span(Div())), Div(Span(Span())), "The elements are different"), - (Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"), -]) -def test_i_can_detect_errors(actual, expected, error_message): - with pytest.raises(AssertionError) as exc_info: - matches(actual, expected) - assert error_message in str(exc_info.value) +class Dummy2: + def __init__(self, attr1, attr2): + self.attr1 = attr1 + self.attr2 = attr2 -@pytest.mark.parametrize('element, expected_path', [ - (Div(), "Path : 'div"), - (Div(Span()), "Path : 'div.span"), - (Div(Span(Div())), "Path : 'div.span.div"), - (Div(id="div_id"), "Path : 'div#div_id"), - (Div(cls="div_class"), "Path : 'div[class=div_class]"), - (Div(name="div_class"), "Path : 'div[name=div_class]"), - (Div(attr="value"), "Path : 'div"), - (Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"), -]) -def test_i_can_properly_show_path(element, expected_path): - def _construct_test_element(source, tail): - res = MyFT(source.tag, source.attrs) - if source.children: - res.children = [_construct_test_element(child, tail) for child in source.children] - else: - res.children = [tail] - return res +class TestMatches: - with pytest.raises(AssertionError) as exc_info: - actual = _construct_test_element(element, "Actual") - expected = _construct_test_element(element, "Expected") - matches(actual, expected) + @pytest.mark.parametrize('actual, expected', [ + (None, None), + (123, 123), + (Div(), Div()), + ([Div(), Span()], [Div(), Span()]), + ({"key": Div(attr="value")}, {"key": Div(attr="value")}), + ({"key": Dummy(attr1="value")}, {"key": TestObject(Dummy, attr1="value")}), + (Div(attr1="value"), Div(attr1="value")), + (Div(attr1="value", attr2="value"), Div(attr1="value")), + (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), + (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), + (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), + (Div(attr1="value"), Div(attr1=AnyValue())), + (None, DoNotCheck()), + (123, DoNotCheck()), + (Div(), DoNotCheck()), + ([Div(), Span()], DoNotCheck()), + (NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked + (Div(), Div(Empty())), + (Div(), Div(NoChildren())), + (Div(attr1="value"), Div(NoChildren())), + (Div(attr1="value1"), Div(AttributeForbidden("attr2"))), + (Div(123), Div(123)), + (Div(Span(123)), Div(Span(123))), + (Div(Span(123)), Div(DoNotCheck())), + (Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")), + (Dummy(123, "value"), TestObject(Dummy, attr2="value")), + (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))), + (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")), + ]) + def test_i_can_match(self, actual, expected): + assert matches(actual, expected) - assert expected_path in str(exc_info.value) - - -def test_i_can_output_error_path(): - elt = Div() - expected = Div() - path = "div#div_id.div.span[class=span_class].p[name=p_name].div" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "id"="div_id" ...', - ' (div ...', - ' (span "class"="span_class" ...', - ' (p "name"="p_name" ...', - ' (div )'] - - -def test_i_can_output_error_attribute(): - elt = Div(attr1="value1", attr2="value2") - expected = elt - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")'] - - -def test_i_can_output_error_attribute_missing_1(): - elt = Div(attr2="value2") - expected = Div(attr1="value1", attr2="value2") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")', - ' ^^^^^^^^^^^^^^^^^^^^^^^ '] - - -def test_i_can_output_error_attribute_missing_2(): - elt = Div(attr1="value1") - expected = Div(attr1="value1", attr2="value2") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")', - ' ^^^^^^^^^^^^^^^^^^^^^^^'] - - -def test_i_can_output_error_attribute_wrong_value(): - elt = Div(attr1="value3", attr2="value2") - expected = Div(attr1="value1", attr2="value2") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")', - ' ^^^^^^^^^^^^^^^^ '] - - -def test_i_can_output_error_constant(): - elt = 123 - expected = elt - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['123'] - - -def test_i_can_output_error_constant_wrong_value(): - elt = 123 - expected = 456 - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['123', - '^^^'] - - -def test_i_can_output_error_when_predicate(): - elt = "before value after" - expected = Contains("value") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ["before value after"] - - -def test_i_can_output_error_when_predicate_wrong_value(): - """I can display error when the condition predicate is not satisfied.""" - elt = "before after" - expected = Contains("value") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ["before after", - "^^^^^^^^^^^^"] - - -def test_i_can_output_error_child_element(): - """I can display error when the element has children""" - elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") - expected = elt - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1"', - ' (p "id"="p_id")', - ' (div "id"="child_1")', - ' (div "id"="child_2")', - ')', - ] - - -def test_i_can_output_error_child_element_text(): - """I can display error when the children is not a FT""" - elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1") - expected = elt - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1"', - ' "Hello world"', - ' (div "id"="child_1")', - ' (div "id"="child_2")', - ')', - ] - - -def test_i_can_output_error_child_element_indicating_sub_children(): - elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1") - expected = elt - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1"', - ' (p "id"="p_id")', - ' (div "id"="child_1" ...)', - ')', - ] - - -def test_i_can_output_error_child_element_wrong_value(): - elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1") - expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1"', - ' (p "id"="p_id")', - ' (div "id"="child_2")', - ' ^^^^^^^^^^^^^^', - ')', - ] - - -def test_i_can_output_error_fewer_elements(): - elt = Div(P(id="p_id"), attr1="value1") - expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") - path = "" - error_output = ErrorOutput(path, elt, expected) - error_output.compute() - assert error_output.output == ['(div "attr1"="value1"', - ' (p "id"="p_id")', - ' ! ** MISSING ** !', - ')', - ] - - -def test_i_can_output_comparison(): - actual = Div(P(id="p_id"), attr1="value1") - expected = actual - actual_out = ErrorOutput("", actual, expected) - expected_out = ErrorOutput("", expected, expected) + @pytest.mark.parametrize('actual, expected, error_message', [ + (None, Div(), "Actual is None"), + (Div(), None, "Actual is not None"), + (123, Div(), "The types are different"), + (123, 124, "The values are different"), + ([Div(), Span()], [], "Actual is bigger than expected"), + ([], [Div(), Span()], "Actual is smaller than expected"), + ("not a list", [Div(), Span()], "The types are different"), + ([Div(), Span()], [Div(), 123], "The types are different"), + (Div(), Span(), "The elements are different"), + ([Div(), Span()], [Div(), Div()], "The elements are different"), + (Div(), Div(attr1="value"), "'attr1' is not found in Actual"), + (Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"), + (Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"), + (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), + (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), + (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), + (Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), + (Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"), + (NotStr("456"), NotStr("123"), "Notstr values are different"), + (Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"), + (Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"), + (Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"), + (Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"), + (Div(), Div(Span()), "Actual is lesser than expected"), + (Div(), Div(123), "Actual is lesser than expected"), + (Div(Span()), Div(Div()), "The elements are different"), + (Div(123), Div(Div()), "The types are different"), + (Div(123), Div(456), "The values are different"), + (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"), + (Div(Span(Div())), Div(Span(Span())), "The elements are different"), + (Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"), + (Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"), + (Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"), + (Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"), + (Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"), + (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"), + (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"), + (Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"), + (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), - comparison_out = ErrorComparisonOutput(actual_out, expected_out) + ]) + def test_i_can_detect_errors(self, actual, expected, error_message): + with pytest.raises(AssertionError) as exc_info: + matches(actual, expected) + assert error_message in str(exc_info.value) - res = comparison_out.render() + @pytest.mark.parametrize('element, expected_path', [ + (Div(), "Path : 'div"), + (Div(Span()), "Path : 'div.span"), + (Div(Span(Div())), "Path : 'div.span.div"), + (Div(id="div_id"), "Path : 'div#div_id"), + (Div(cls="div_class"), "Path : 'div[class=div_class]"), + (Div(name="div_class"), "Path : 'div[name=div_class]"), + (Div(attr="value"), "Path : 'div"), + (Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"), + ]) + def test_i_can_properly_show_path(self, element, expected_path): + def _construct_test_element(source, tail): + res = MyFT(source.tag, source.attrs) + if source.children: + res.children = [_construct_test_element(child, tail) for child in source.children] + else: + res.children = [tail] + return res + + with pytest.raises(AssertionError) as exc_info: + actual = _construct_test_element(element, "Actual") + expected = _construct_test_element(element, "Expected") + matches(actual, expected) + + assert expected_path in str(exc_info.value) + + +class TestErrorOutput: + def test_i_can_output_error_path(self): + """The output follows the representation of the given path""" + elt = Div() + expected = Div() + path = "div#div_id.div.span[class=span_class].p[name=p_name].div" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "id"="div_id" ...', + ' (div ...', + ' (span "class"="span_class" ...', + ' (p "name"="p_name" ...', + ' (div )'] - assert "\n" + res == ''' + def test_i_can_output_error_attribute(self): + elt = Div(attr1="value1", attr2="value2") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")'] + + def test_i_can_output_error_attribute_missing_1(self): + elt = Div(attr2="value2") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")', + ' ^^^^^^^^^^^^^^^^^^^^^^^ '] + + def test_i_can_output_error_attribute_missing_2(self): + elt = Div(attr1="value1") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")', + ' ^^^^^^^^^^^^^^^^^^^^^^^'] + + def test_i_can_output_error_attribute_wrong_value(self): + elt = Div(attr1="value3", attr2="value2") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")', + ' ^^^^^^^^^^^^^^^^ '] + + def test_i_can_output_error_constant(self): + elt = 123 + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['123'] + + def test_i_can_output_error_constant_wrong_value(self): + elt = 123 + expected = 456 + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['123', + '^^^'] + + def test_i_can_output_error_when_predicate(self): + elt = "before value after" + expected = Contains("value") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ["before value after"] + + def test_i_can_output_error_when_predicate_wrong_value(self): + """I can display error when the condition predicate is not satisfied.""" + elt = "before after" + expected = Contains("value") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ["before after", + "^^^^^^^^^^^^"] + + def test_i_can_output_error_child_element(self): + """I can display error when the element has children""" + elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_1")', + ' (div "id"="child_2")', + ')', + ] + + def test_i_can_output_error_child_element_text(self): + """I can display error when the children is not a FT""" + elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' "Hello world"', + ' (div "id"="child_1")', + ' (div "id"="child_2")', + ')', + ] + + def test_i_can_output_error_child_element_indicating_sub_children(self): + elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_1" ...)', + ')', + ] + + def test_i_can_output_error_child_element_wrong_value(self): + elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1") + expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_2")', + ' ^^^^^^^^^^^^^^', + ')', + ] + + def test_i_can_output_error_fewer_elements(self): + elt = Div(P(id="p_id"), attr1="value1") + expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' ! ** MISSING ** !', + ')', + ] + + def test_i_can_output_error_test_object(self): + elt = TestObject(Dummy, attr1=123, attr2="value2") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(Dummy "attr1"="123" "attr2"="value2")'] + + def test_i_can_output_error_test_object_wrong_type(self): + elt = Div(attr1=123, attr2="value2") + expected = TestObject(Dummy, attr1=123, attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == [ + '(div "attr1"="123" "attr2"="value2")', + ' ^^^ ' + ] + + def test_i_can_output_error_test_object_wrong_type_2(self): + elt = Dummy2(attr1=123, attr2="value2") + expected = TestObject(Dummy, attr1=123, attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == [ + '(Dummy2 "attr1"="123" "attr2"="value2")', + ' ^^^^^^ ' + ] + + def test_i_can_output_error_test_object_wrong_type_3(self): + elt = Div(attr1=123, attr2="value2") + expected = TestObject("Dummy", attr1=123, attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == [ + '(div "attr1"="123" "attr2"="value2")', + ' ^^^ ' + ] + + def test_i_can_output_error_test_object_wrong_value(self): + elt = Dummy(attr1="456", attr2="value2") + expected = TestObject(Dummy, attr1="123", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == [ + '(Dummy "attr1"="456" "attr2"="value2")', + ' ^^^^^^^^^^^^^ ' + ] + + +class TestErrorComparisonOutput: + def test_i_can_output_comparison(self): + actual = Div(P(id="p_id"), attr1="value1") + expected = actual + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' (div "attr1"="value1" | (div "attr1"="value1" (p "id"="p_id") | (p "id"="p_id") ) | )''' - - -def test_i_can_output_comparison_with_path(): - actual = Div(P(id="p_id"), attr1="value1") - expected = actual - actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected) - expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected) - comparison_out = ErrorComparisonOutput(actual_out, expected_out) - - res = comparison_out.render() - - assert "\n" + res == ''' + def test_i_can_output_comparison_with_path(self): + actual = Div(P(id="p_id"), attr1="value1") + expected = actual + actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected) + expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' (div "id"="div_id" ... | (div "id"="div_id" ... (span "class"="cls" ... | (span "class"="cls" ... (div "attr1"="value1" | (div "attr1"="value1" (p "id"="p_id") | (p "id"="p_id") ) | )''' - - -def test_i_can_output_comparison_when_missing_attributes(): - actual = Div(P(id="p_id"), attr1="value1") - expected = Div(P(id="p_id"), attr2="value1") - actual_out = ErrorOutput("", actual, expected) - expected_out = ErrorOutput("", expected, expected) - comparison_out = ErrorComparisonOutput(actual_out, expected_out) - - res = comparison_out.render() - - assert "\n" + res == ''' + def test_i_can_output_comparison_when_missing_attributes(self): + actual = Div(P(id="p_id"), attr1="value1") + expected = Div(P(id="p_id"), attr2="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' (div "attr2"="** MISSING **" | (div "attr2"="value1" ^^^^^^^^^^^^^^^^^^^^^^^ | (p "id"="p_id") | (p "id"="p_id") ) | )''' - - -def test_i_can_output_comparison_when_wrong_attributes(): - actual = Div(P(id="p_id"), attr1="value2") - expected = Div(P(id="p_id"), attr1="value1") - actual_out = ErrorOutput("", actual, expected) - expected_out = ErrorOutput("", expected, expected) - comparison_out = ErrorComparisonOutput(actual_out, expected_out) - - res = comparison_out.render() - - assert "\n" + res == ''' + def test_i_can_output_comparison_when_wrong_attributes(self): + actual = Div(P(id="p_id"), attr1="value2") + expected = Div(P(id="p_id"), attr1="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' (div "attr1"="value2" | (div "attr1"="value1" ^^^^^^^^^^^^^^^^ | (p "id"="p_id") | (p "id"="p_id") ) | )''' - - -def test_i_can_output_comparison_when_fewer_elements(): - actual = Div(P(id="p_id"), attr1="value1") - expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1") - actual_out = ErrorOutput("", actual, expected) - expected_out = ErrorOutput("", expected, expected) - comparison_out = ErrorComparisonOutput(actual_out, expected_out) - - res = comparison_out.render() - - assert "\n" + res == ''' + def test_i_can_output_comparison_when_fewer_elements(self): + actual = Div(P(id="p_id"), attr1="value1") + expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' (div "attr1"="value1" | (div "attr1"="value1" (p "id"="p_id") | (span "id"="s_id") ^ ^^^^^^^^^^^ | ! ** MISSING ** ! | (p "id"="p_id") ) | )''' - - -def test_i_can_see_the_diff_when_matching(): - actual = Div(attr1="value1") - expected = Div(attr1=Contains("value2")) - with pytest.raises(AssertionError) as exc_info: - matches(actual, expected) - - debug_output = str(exc_info.value) - assert "\n" + debug_output == """ + def test_i_can_see_the_diff_when_matching(self): + actual = Div(attr1="value1") + expected = Div(attr1=Contains("value2")) + + with pytest.raises(AssertionError) as exc_info: + matches(actual, expected) + + debug_output = str(exc_info.value) + assert "\n" + debug_output == """ Path : 'div' Error : The condition 'Contains(value2)' is not satisfied. (div "attr1"="value1") | (div "attr1"="Contains(value2)") ^^^^^^^^^^^^^^^^ |""" + + def test_i_can_see_the_diff_with_test_object_when_wrong_type(self): + actual = Div(attr1=123, attr2="value2") + expected = TestObject(Dummy, attr1=123, attr2="value2") + + actual_out = ErrorOutput("dummy", actual, expected) + expected_out = ErrorOutput("div", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2") + ^^^ |'''