817 lines
33 KiB
Python
817 lines
33 KiB
Python
import shutil
|
|
|
|
import pytest
|
|
from fasthtml.common import Div, Span
|
|
|
|
from myfasthtml.controls.TabsManager import TabsManager
|
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
|
from myfasthtml.core.instances import InstancesManager
|
|
from myfasthtml.test.matcher import matches, find_one, find, Contains, TestIcon, TestScript, TestObject, DoesNotContain, \
|
|
And, TestIconNotStr
|
|
|
|
|
|
@pytest.fixture()
|
|
def tabs_manager(root_instance):
|
|
"""Create a fresh TabsManager instance for each test."""
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
yield TabsManager(root_instance)
|
|
|
|
InstancesManager.reset()
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
|
|
|
|
class TestTabsManagerBehaviour:
|
|
"""Tests for TabsManager behavior and logic."""
|
|
|
|
# =========================================================================
|
|
# Initialization
|
|
# =========================================================================
|
|
|
|
def test_i_can_create_tabs_manager(self, root_instance):
|
|
"""Test that TabsManager can be created with default state."""
|
|
tm = TabsManager(root_instance)
|
|
|
|
assert tm is not None
|
|
assert tm.get_state().tabs == {}
|
|
assert tm.get_state().tabs_order == []
|
|
assert tm.get_state().active_tab is None
|
|
assert tm.get_state().ns_tabs_content == {}
|
|
assert tm.get_state().ns_tabs_sent_to_client == set()
|
|
assert tm._tab_count == 0
|
|
|
|
# =========================================================================
|
|
# Tab Creation
|
|
# =========================================================================
|
|
|
|
def test_i_can_create_tab_with_simple_component(self, tabs_manager):
|
|
"""Test creating a tab with a simple Div component."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
assert tab_id is not None
|
|
assert tab_id in tabs_manager.get_state().tabs
|
|
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
|
|
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
|
|
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None
|
|
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None
|
|
assert tabs_manager.get_state().tabs_order == [tab_id]
|
|
assert tabs_manager.get_state().active_tab == tab_id
|
|
|
|
def test_i_can_create_tab_with_base_instance(self, tabs_manager):
|
|
"""Test creating a tab with a BaseInstance component (VisNetwork)."""
|
|
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
|
tab_id = tabs_manager.create_tab("Network", vis_network)
|
|
|
|
assert tab_id is not None
|
|
assert tabs_manager.get_state().tabs[tab_id]["component_type"] == vis_network.get_prefix()
|
|
assert tabs_manager.get_state().tabs[tab_id]["component_id"] == vis_network.get_id()
|
|
|
|
def test_i_can_create_multiple_tabs(self, tabs_manager):
|
|
"""Test creating multiple tabs maintains correct order and activation."""
|
|
tab_id1 = tabs_manager.create_tab("Users", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("User2", Div("Content 2"))
|
|
|
|
assert len(tabs_manager.get_state().tabs) == 2
|
|
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
|
|
assert tabs_manager.get_state().active_tab == tab_id2
|
|
|
|
def test_created_tab_is_activated_by_default(self, tabs_manager):
|
|
"""Test that newly created tab becomes active by default."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
assert tabs_manager.get_state().active_tab == tab_id
|
|
|
|
def test_i_can_create_tab_without_activating(self, tabs_manager):
|
|
"""Test creating a tab without activating it."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"), activate=False)
|
|
|
|
assert tab_id2 in tabs_manager.get_state().tabs
|
|
assert tabs_manager.get_state().active_tab == tab_id1
|
|
|
|
# =========================================================================
|
|
# Tab Activation
|
|
# =========================================================================
|
|
|
|
def test_i_can_show_existing_tab(self, tabs_manager):
|
|
"""Test showing an existing tab activates it."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
assert tabs_manager.get_state().active_tab == tab_id2
|
|
|
|
tabs_manager.show_tab(tab_id1)
|
|
assert tabs_manager.get_state().active_tab == tab_id1
|
|
|
|
def test_i_can_show_tab_without_activating(self, tabs_manager):
|
|
"""Test showing a tab without changing active tab."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
assert tabs_manager.get_state().active_tab == tab_id2
|
|
|
|
tabs_manager.show_tab(tab_id1, activate=False)
|
|
assert tabs_manager.get_state().active_tab == tab_id2
|
|
|
|
def test_show_tab_returns_controller_only_when_already_sent(self, tabs_manager):
|
|
"""Test that show_tab returns only controller when tab was already sent to client."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
# First call: tab not sent yet, returns controller + header + content
|
|
result1 = tabs_manager.show_tab(tab_id, oob=False)
|
|
assert len(result1) == 3 # controller, header, wrapped_content
|
|
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
|
|
|
# Second call: tab already sent, returns only controller
|
|
result2 = tabs_manager.show_tab(tab_id, oob=False)
|
|
assert result2 is not None
|
|
# Result2 is a single Div (controller), not a tuple
|
|
assert hasattr(result2, 'tag') and result2.tag == 'div'
|
|
|
|
def test_i_cannot_show_nonexistent_tab(self, tabs_manager):
|
|
"""Test that showing a nonexistent tab returns None."""
|
|
result = tabs_manager.show_tab("nonexistent_id")
|
|
|
|
assert result is None
|
|
|
|
# =========================================================================
|
|
# Tab Closing
|
|
# =========================================================================
|
|
|
|
def test_i_can_close_tab(self, tabs_manager):
|
|
"""Test closing a tab removes it from state."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
|
|
|
tabs_manager.close_tab(tab_id2)
|
|
|
|
assert len(tabs_manager.get_state().tabs) == 2
|
|
assert tab_id2 not in tabs_manager.get_state().tabs
|
|
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
|
|
assert tabs_manager.get_state().active_tab == tab_id3
|
|
|
|
def test_closing_active_tab_activates_first_remaining(self, tabs_manager):
|
|
"""Test that closing the active tab activates the first remaining tab."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
|
|
|
tabs_manager.close_tab(tab_id3) # close the currently active tab
|
|
assert tabs_manager.get_state().active_tab == tab_id1
|
|
|
|
def test_closing_last_tab_sets_active_to_none(self, tabs_manager):
|
|
"""Test that closing the last remaining tab sets active_tab to None."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
tabs_manager.close_tab(tab_id)
|
|
|
|
assert len(tabs_manager.get_state().tabs) == 0
|
|
assert tabs_manager.get_state().active_tab is None
|
|
|
|
def test_close_tab_cleans_cache(self, tabs_manager):
|
|
"""Test that closing a tab removes it from content cache."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
# Trigger cache population
|
|
tabs_manager._get_or_create_tab_content(tab_id)
|
|
assert tab_id in tabs_manager.get_state().ns_tabs_content
|
|
|
|
tabs_manager.close_tab(tab_id)
|
|
|
|
assert tab_id not in tabs_manager.get_state().ns_tabs_content
|
|
|
|
def test_close_tab_cleans_sent_to_client(self, tabs_manager):
|
|
"""Test that closing a tab removes it from ns_tabs_sent_to_client."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
# Send tab to client
|
|
tabs_manager.show_tab(tab_id)
|
|
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
|
|
|
tabs_manager.close_tab(tab_id)
|
|
|
|
assert tab_id not in tabs_manager.get_state().ns_tabs_sent_to_client
|
|
|
|
def test_i_cannot_close_nonexistent_tab(self, tabs_manager):
|
|
"""Test that closing a nonexistent tab returns self without error."""
|
|
result = tabs_manager.close_tab("nonexistent_id")
|
|
|
|
assert result == tabs_manager
|
|
|
|
# =========================================================================
|
|
# Content Management
|
|
# =========================================================================
|
|
|
|
def test_cached_content_is_reused(self, tabs_manager):
|
|
"""Test that cached content is returned without re-creation."""
|
|
content = Div("Test Content")
|
|
tab_id = tabs_manager.create_tab("Tab1", content)
|
|
|
|
# First call creates cache
|
|
result1 = tabs_manager._get_or_create_tab_content(tab_id)
|
|
|
|
# Second call should return same object from cache
|
|
result2 = tabs_manager._get_or_create_tab_content(tab_id)
|
|
|
|
assert result1 is result2
|
|
|
|
def test_dynamic_get_content_for_base_instance(self, tabs_manager):
|
|
"""Test that _dynamic_get_content retrieves BaseInstance from InstancesManager."""
|
|
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
|
tab_id = tabs_manager.create_tab("Network", vis_network)
|
|
|
|
# Get content dynamically
|
|
result = tabs_manager._dynamic_get_content(tab_id)
|
|
|
|
assert result == vis_network
|
|
|
|
def test_dynamic_get_content_for_simple_component(self, tabs_manager):
|
|
"""Test that _dynamic_get_content returns error message for non-BaseInstance."""
|
|
content = Div("Simple content")
|
|
tab_id = tabs_manager.create_tab("Tab1", content)
|
|
|
|
# Get content dynamically (should fail gracefully)
|
|
result = tabs_manager._dynamic_get_content(tab_id)
|
|
|
|
# Should return error Div since Div is not serializable
|
|
assert matches(result, Div('Tab content does not support serialization.'))
|
|
|
|
def test_dynamic_get_content_for_nonexistent_tab(self, tabs_manager):
|
|
"""Test that _dynamic_get_content returns error for nonexistent tab."""
|
|
result = tabs_manager._dynamic_get_content("nonexistent_id")
|
|
|
|
assert matches(result, Div('Tab not found.'))
|
|
|
|
# =========================================================================
|
|
# Content Change
|
|
# =========================================================================
|
|
|
|
def test_i_can_change_tab_content(self, tabs_manager):
|
|
"""Test changing the content of an existing tab."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Original Content"))
|
|
|
|
new_content = Div("New Content")
|
|
tabs_manager.change_tab_content(tab_id, "Updated Tab", new_content)
|
|
|
|
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Updated Tab"
|
|
assert tabs_manager.get_state().ns_tabs_content[tab_id] == new_content
|
|
|
|
def test_change_tab_content_invalidates_cache(self, tabs_manager):
|
|
"""Test that changing tab content invalidates the sent_to_client cache."""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Original"))
|
|
|
|
# Send tab to client
|
|
tabs_manager.show_tab(tab_id)
|
|
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
|
|
|
# Change content
|
|
tabs_manager.change_tab_content(tab_id, "Tab1", Div("New Content"))
|
|
|
|
# Cache should be invalidated (discard was called)
|
|
# The show_tab inside change_tab_content will re-add it, but the test
|
|
# verifies that discard was called by checking the behavior
|
|
# Since show_tab is called after discard, tab will be in sent_to_client again
|
|
# We verify the invalidation worked by checking the method was called
|
|
# This is more of an integration test
|
|
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
|
|
|
|
def test_i_cannot_change_content_of_nonexistent_tab(self, tabs_manager):
|
|
"""Test that changing content of nonexistent tab returns None."""
|
|
result = tabs_manager.change_tab_content("nonexistent", "Label", Div("Content"))
|
|
|
|
assert result is None
|
|
|
|
# =========================================================================
|
|
# Auto-increment
|
|
# =========================================================================
|
|
|
|
def test_on_new_tab_with_auto_increment(self, tabs_manager):
|
|
"""Test that on_new_tab with auto_increment appends counter to label."""
|
|
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
|
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
|
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
|
|
|
|
tabs = list(tabs_manager.get_state().tabs.values())
|
|
assert tabs[0]["label"] == "Untitled_0"
|
|
assert tabs[1]["label"] == "Untitled_1"
|
|
assert tabs[2]["label"] == "Untitled_2"
|
|
|
|
def test_each_instance_has_independent_counter(self, root_instance):
|
|
"""Test that each TabsManager instance has its own independent counter."""
|
|
tm1 = TabsManager(root_instance, _id="tm1")
|
|
tm2 = TabsManager(root_instance, _id="tm2")
|
|
|
|
tm1.on_new_tab("Tab", None, auto_increment=True)
|
|
tm1.on_new_tab("Tab", None, auto_increment=True)
|
|
|
|
tm2.on_new_tab("Tab", None, auto_increment=True)
|
|
|
|
# tm1 should have Tab_0 and Tab_1
|
|
tabs_tm1 = list(tm1.get_state().tabs.values())
|
|
assert tabs_tm1[0]["label"] == "Tab_0"
|
|
assert tabs_tm1[1]["label"] == "Tab_1"
|
|
|
|
# tm2 should have Tab_0 (independent counter)
|
|
tabs_tm2 = list(tm2.get_state().tabs.values())
|
|
assert tabs_tm2[0]["label"] == "Tab_0"
|
|
|
|
def test_auto_increment_uses_default_component_when_none(self, tabs_manager):
|
|
"""Test that on_new_tab uses VisNetwork when component is None."""
|
|
tabs_manager.on_new_tab("Network", None, auto_increment=False)
|
|
|
|
tab_id = tabs_manager.get_state().tabs_order[0]
|
|
content = tabs_manager.get_state().ns_tabs_content[tab_id]
|
|
|
|
assert isinstance(content, VisNetwork)
|
|
|
|
# =========================================================================
|
|
# Duplicate Detection
|
|
# =========================================================================
|
|
|
|
def test_tab_already_exists_with_same_label_and_component(self, tabs_manager):
|
|
"""Test that duplicate tab is detected and returns existing tab_id."""
|
|
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
|
|
|
tab_id1 = tabs_manager.create_tab("Network", vis_network)
|
|
tab_id2 = tabs_manager.create_tab("Network", vis_network)
|
|
|
|
# Should return same tab_id
|
|
assert tab_id1 == tab_id2
|
|
assert len(tabs_manager.get_state().tabs) == 1
|
|
|
|
def test_tab_already_exists_returns_none_for_different_label(self, tabs_manager):
|
|
"""Test that same component with different label creates new tab."""
|
|
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
|
|
|
|
tab_id1 = tabs_manager.create_tab("Network 1", vis_network)
|
|
tab_id2 = tabs_manager.create_tab("Network 2", vis_network)
|
|
|
|
# Should create two different tabs
|
|
assert tab_id1 != tab_id2
|
|
assert len(tabs_manager.get_state().tabs) == 2
|
|
|
|
def test_tab_already_exists_returns_none_for_non_base_instance(self, tabs_manager):
|
|
"""Test that Div components are never considered duplicates."""
|
|
content = Div("Content")
|
|
|
|
tab_id1 = tabs_manager.create_tab("Tab", content)
|
|
tab_id2 = tabs_manager.create_tab("Tab", content)
|
|
|
|
# Should create two different tabs (Div is not BaseInstance)
|
|
assert tab_id1 != tab_id2
|
|
assert len(tabs_manager.get_state().tabs) == 2
|
|
|
|
# =========================================================================
|
|
# Edge Cases
|
|
# =========================================================================
|
|
|
|
def test_get_ordered_tabs_respects_order(self, tabs_manager):
|
|
"""Test that _get_ordered_tabs returns tabs in correct order."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
|
|
|
ordered = tabs_manager._get_ordered_tabs()
|
|
|
|
assert list(ordered.keys()) == [tab_id1, tab_id2, tab_id3]
|
|
|
|
def test_get_tab_list_returns_only_existing_tabs(self, tabs_manager):
|
|
"""Test that _get_tab_list is robust to inconsistent state."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
# Manually corrupt state (tab_id in order but not in tabs dict)
|
|
tabs_manager._state.tabs_order.append("fake_id")
|
|
|
|
tab_list = tabs_manager._get_tab_list()
|
|
|
|
# Should only return existing tabs
|
|
assert len(tab_list) == 2
|
|
assert all(tab["id"] in [tab_id1, tab_id2] for tab in tab_list)
|
|
|
|
def test_state_update_propagates_to_search(self, tabs_manager):
|
|
"""Test that state updates propagate to the Search component."""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
|
|
# Search should have 1 item
|
|
assert len(tabs_manager._search.get_items()) == 1
|
|
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
# Search should have 2 items
|
|
assert len(tabs_manager._search.get_items()) == 2
|
|
|
|
tabs_manager.close_tab(tab_id1)
|
|
|
|
# Search should have 1 item again
|
|
assert len(tabs_manager._search.get_items()) == 1
|
|
|
|
def test_multiple_tabs_managers_in_same_session(self, root_instance):
|
|
"""Test that multiple TabsManager instances can coexist in same session."""
|
|
tm1 = TabsManager(root_instance, _id="tm1")
|
|
tm2 = TabsManager(root_instance, _id="tm2")
|
|
|
|
tm1.create_tab("Tab1", Div("Content 1"))
|
|
tm2.create_tab("Tab2", Div("Content 2"))
|
|
|
|
assert len(tm1.get_state().tabs) == 1
|
|
assert len(tm2.get_state().tabs) == 1
|
|
assert tm1.get_id() != tm2.get_id()
|
|
|
|
|
|
class TestTabsManagerRender:
|
|
"""Tests for TabsManager HTML rendering."""
|
|
|
|
@pytest.fixture
|
|
def tabs_manager(self, root_instance):
|
|
"""Create a fresh TabsManager instance for render tests."""
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
tm = TabsManager(root_instance)
|
|
yield tm
|
|
InstancesManager.reset()
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
|
|
# =========================================================================
|
|
# Controller
|
|
# =========================================================================
|
|
|
|
def test_i_can_render_tabs_manager_with_no_tabs(self, tabs_manager):
|
|
"""Test that TabsManager renders with no tabs."""
|
|
html = tabs_manager.render()
|
|
expected = Div(
|
|
Div(id=f"{tabs_manager.get_id()}-controller"), # controller
|
|
Div(id=f"{tabs_manager.get_id()}-header-wrapper"), # header
|
|
Div(id=f"{tabs_manager.get_id()}-content-wrapper"), # active content
|
|
id=tabs_manager.get_id(),
|
|
)
|
|
|
|
assert matches(html, expected)
|
|
|
|
def test_controller_has_correct_id_and_attributes(self, tabs_manager):
|
|
"""Test that controller has correct ID, data attribute, and HTMX script.
|
|
|
|
Why these elements matter:
|
|
- id: Required for HTMX targeting in commands
|
|
- data_active_tab: Stores active tab ID for JavaScript access
|
|
- hx_on__after_settle: Triggers updateTabs() after HTMX swap completes
|
|
"""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("Content"))
|
|
controller = tabs_manager._mk_tabs_controller(oob=False)
|
|
|
|
expected = Div(
|
|
id=f"{tabs_manager.get_id()}-controller",
|
|
data_active_tab=tab_id,
|
|
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");'
|
|
)
|
|
|
|
assert matches(controller, expected)
|
|
|
|
def test_controller_with_oob_false(self, tabs_manager):
|
|
"""Test that controller has no swap attribute when oob=False.
|
|
|
|
Why this matters:
|
|
- hx_swap_oob controls whether element swaps out-of-band
|
|
- When False, element swaps into target specified in command
|
|
"""
|
|
controller = tabs_manager._mk_tabs_controller(oob=False)
|
|
|
|
# Controller should not have hx_swap_oob attribute
|
|
assert not hasattr(controller, 'hx_swap_oob') or controller.attrs.get('hx_swap_oob') is None
|
|
|
|
def test_controller_with_oob_true(self, tabs_manager):
|
|
"""Test that controller has swap attribute when oob=True.
|
|
|
|
Why this matters:
|
|
- hx_swap_oob="true" enables out-of-band swapping
|
|
- Allows updating controller independently of main target
|
|
"""
|
|
controller = tabs_manager._mk_tabs_controller(oob=True)
|
|
|
|
expected = Div(hx_swap_oob="true")
|
|
assert matches(controller, expected)
|
|
|
|
# =========================================================================
|
|
# Header
|
|
# =========================================================================
|
|
|
|
def test_header_wrapper_with_no_tabs(self, tabs_manager):
|
|
"""Test that header wrapper renders empty when no tabs exist.
|
|
|
|
Why these elements matter:
|
|
- id: Required for targeting header in updates
|
|
- cls: Provides base styling for header
|
|
- Empty header div: Shows no tabs are present
|
|
- Search menu: Always present for adding tabs
|
|
"""
|
|
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
|
|
|
# Should have no children (no tabs)
|
|
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
|
|
assert len(tab_buttons) == 0, "Header should contain no tab buttons when empty"
|
|
|
|
def test_header_wrapper_with_multiple_tabs(self, tabs_manager):
|
|
"""Test that header wrapper renders all tab buttons.
|
|
|
|
Why these elements matter:
|
|
- Multiple Div elements: Each represents a tab button
|
|
- Order preservation: Tabs must appear in creation order
|
|
- cls mf-tab-button: Identifies tab buttons for styling/selection
|
|
"""
|
|
tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
tabs_manager.create_tab("Tab3", Div("Content 3"))
|
|
|
|
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
|
|
|
# Should have 3 tab buttons
|
|
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
|
|
assert len(tab_buttons) == 3, "Header should contain exactly 3 tab buttons"
|
|
|
|
def test_header_wrapper_contains_search_menu(self, tabs_manager):
|
|
"""Test that header wrapper contains the search menu dropdown.
|
|
|
|
Why these elements matter:
|
|
- dropdown: DaisyUI dropdown container for tab search
|
|
- dropdown-end: Positions dropdown at end of header
|
|
- TestIcon for tabs icon: Visual indicator for menu
|
|
- Search component: Provides tab search functionality
|
|
"""
|
|
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
|
|
|
|
# Find dropdown menu
|
|
dropdown = find_one(header, Div(cls=Contains("dropdown", "dropdown-end")))
|
|
|
|
# Should contain tabs icon
|
|
icon = find_one(dropdown, TestIcon("tabs24_regular"))
|
|
assert icon is not None, "Dropdown should contain tabs icon"
|
|
|
|
# Should contain Search component
|
|
search = find_one(dropdown, TestObject(tabs_manager._search.__class__))
|
|
assert search is not None, "Dropdown should contain Search component"
|
|
|
|
# =========================================================================
|
|
# Tab Button
|
|
# =========================================================================
|
|
|
|
def test_tab_button_for_active_tab(self, tabs_manager):
|
|
"""Test that active tab button has the active class.
|
|
|
|
Why these elements matter:
|
|
- cls mf-tab-active: Highlights the currently active tab
|
|
- data_tab_id: Links button to specific tab
|
|
- data_manager_id: Links button to TabsManager instance
|
|
"""
|
|
tab_id = tabs_manager.create_tab("Active Tab", Div("Content"))
|
|
tab_data = tabs_manager.get_state().tabs[tab_id]
|
|
|
|
button = tabs_manager._mk_tab_button(tab_data)
|
|
|
|
expected = Div(
|
|
cls=Contains("mf-tab-button", "mf-tab-active"),
|
|
data_tab_id=tab_id,
|
|
data_manager_id=tabs_manager.get_id()
|
|
)
|
|
|
|
assert matches(button, expected)
|
|
|
|
def test_tab_button_for_inactive_tab(self, tabs_manager):
|
|
"""Test that inactive tab button does not have active class.
|
|
|
|
Why these elements matter:
|
|
- cls without mf-tab-active: Indicates tab is not active
|
|
- Visual distinction: User can see which tab is selected
|
|
"""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
# tab_id1 is now inactive
|
|
tab_data = tabs_manager.get_state().tabs[tab_id1]
|
|
button = tabs_manager._mk_tab_button(tab_data)
|
|
|
|
expected = Div(
|
|
cls=And(DoesNotContain("mf-tab-active"), Contains("mf-tab-button")),
|
|
data_tab_id=tab_id1,
|
|
data_manager_id=tabs_manager.get_id()
|
|
)
|
|
|
|
assert matches(button, expected)
|
|
|
|
def test_tab_button_has_label_and_close_icon(self, tabs_manager):
|
|
"""Test that tab button contains label and close icon.
|
|
|
|
Why these elements matter:
|
|
- Label Span: Displays tab name
|
|
- Close icon: Allows user to close the tab
|
|
- cls mf-tab-label: Styles the label text
|
|
- cls mf-tab-close-btn: Styles the close button
|
|
"""
|
|
tab_id = tabs_manager.create_tab("My Tab", Div("Content"))
|
|
tab_data = tabs_manager.get_state().tabs[tab_id]
|
|
|
|
button = tabs_manager._mk_tab_button(tab_data)
|
|
|
|
expected = Div(
|
|
Span("My Tab", cls=Contains("mf-tab-label")),
|
|
Span(TestIconNotStr("dismiss_circle16_regular"), cls=Contains("mf-tab-close-btn")),
|
|
)
|
|
|
|
assert matches(button, expected)
|
|
|
|
# =========================================================================
|
|
# Content
|
|
# =========================================================================
|
|
|
|
def test_content_wrapper_when_no_active_tab(self, tabs_manager):
|
|
"""Test that content wrapper shows 'No Content' when no tab is active.
|
|
|
|
Why these elements matter:
|
|
- id content-wrapper: Container for all tab contents
|
|
- 'No Content' message: Informs user no tab is selected
|
|
- cls hidden: Hides empty content by default
|
|
"""
|
|
wrapper = tabs_manager._mk_tab_content_wrapper()
|
|
|
|
# Should contain "No Content" message
|
|
content = find_one(wrapper, Div(cls=Contains("mf-empty-content")))
|
|
assert "No Content" in str(content)
|
|
|
|
def test_content_wrapper_when_tab_active(self, tabs_manager):
|
|
"""Test that content wrapper shows active tab content.
|
|
|
|
Why these elements matter:
|
|
- Active tab content is visible
|
|
- Content is wrapped in mf-tab-content div
|
|
- Correct tab ID in content div ID
|
|
"""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
|
|
|
wrapper = tabs_manager._mk_tab_content_wrapper()
|
|
|
|
# global view from the wrapper
|
|
expected = Div(
|
|
Div(), # tab content, tested just after
|
|
id=f"{tabs_manager.get_id()}-content-wrapper",
|
|
cls=Contains("mf-tab-content-wrapper"),
|
|
)
|
|
assert matches(wrapper, expected)
|
|
|
|
# check if the content is present
|
|
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
|
expected = Div(
|
|
Div("My Content"), # <= content
|
|
cls=Contains("mf-tab-content"),
|
|
)
|
|
|
|
assert matches(tab_content, expected)
|
|
|
|
def test_tab_content_for_active_tab(self, tabs_manager):
|
|
"""Test that active tab content does not have hidden class.
|
|
|
|
Why these elements matter:
|
|
- No 'hidden' class: Content is visible to user
|
|
- Correct content ID: Links to specific tab
|
|
"""
|
|
content_div = Div("Test Content")
|
|
tab_id = tabs_manager.create_tab("Tab1", content_div)
|
|
|
|
tab_content_wrapper = tabs_manager._mk_tab_content(tab_id, content_div)
|
|
tab_content = find_one(tab_content_wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
|
|
|
|
# Should not have 'hidden' in classes
|
|
classes = tab_content.attrs.get('class', '')
|
|
assert 'hidden' not in classes
|
|
assert 'mf-tab-content' in classes
|
|
|
|
def test_tab_content_for_inactive_tab(self, tabs_manager):
|
|
"""Test that inactive tab content has hidden class.
|
|
|
|
Why these elements matter:
|
|
- 'hidden' class: Hides inactive tab content
|
|
- Content still in DOM: Enables fast tab switching
|
|
"""
|
|
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
|
|
# tab_id1 is now inactive
|
|
content_div = Div("Content 1")
|
|
tab_content = tabs_manager._mk_tab_content(tab_id1, content_div)
|
|
|
|
# Should have 'hidden' class
|
|
expected = Div(cls=Contains("hidden"))
|
|
assert matches(tab_content, expected)
|
|
|
|
def test_i_can_show_a_new_content(self, tabs_manager):
|
|
"""Test that TabsManager.show_tab() send the correct div to the client"""
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
|
actual = tabs_manager.show_tab(tab_id)
|
|
|
|
expected = (
|
|
Div(data_active_tab=tab_id,
|
|
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
|
|
hx_swap_oob="true"), # the controller is correctly updated
|
|
Div(
|
|
id=f'{tabs_manager.get_id()}-header-wrapper',
|
|
hx_swap_oob="true"
|
|
), # content of the header
|
|
Div(
|
|
Div(Div("My Content")),
|
|
hx_swap_oob=f"beforeend:#{tabs_manager.get_id()}-content-wrapper", # hx_swap_oob="beforeend:" important !
|
|
), # content + where to put it
|
|
)
|
|
assert matches(actual, expected)
|
|
|
|
def test_i_can_show_content_after_switch(self, tabs_manager):
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
|
tabs_manager.show_tab(tab_id) # first time, send everything
|
|
actual = tabs_manager.show_tab(tab_id) # second time, send only the controller
|
|
|
|
expected = Div(data_active_tab=tab_id,
|
|
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
|
|
hx_swap_oob="true")
|
|
|
|
assert matches(actual, expected)
|
|
|
|
def test_i_can_close_a_tab(self, tabs_manager):
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
|
tabs_manager.show_tab(tab_id) # was sent
|
|
actual = tabs_manager.close_tab(tab_id)
|
|
|
|
expected = (
|
|
Div(id=f'{tabs_manager.get_id()}-controller'),
|
|
Div(id=f'{tabs_manager.get_id()}-header-wrapper'),
|
|
Div(id=f'{tabs_manager.get_id()}-{tab_id}-content', hx_swap_oob="delete") # hx_swap_oob="delete" important !
|
|
)
|
|
|
|
assert matches(actual, expected)
|
|
|
|
def test_i_can_change_content(self, tabs_manager):
|
|
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
|
|
tabs_manager.show_tab(tab_id)
|
|
actual = tabs_manager.change_tab_content(tab_id, "New Label", Div("New Content"))
|
|
|
|
expected = (
|
|
Div(data_active_tab=tab_id, hx_swap_oob="true"),
|
|
Div(id=f'{tabs_manager.get_id()}-header-wrapper', hx_swap_oob="true"),
|
|
Div(
|
|
Div("New Content"),
|
|
id=f'{tabs_manager.get_id()}-{tab_id}-content',
|
|
hx_swap_oob="outerHTML", # hx_swap_oob="true" important !
|
|
),
|
|
)
|
|
assert matches(actual, expected)
|
|
|
|
# =========================================================================
|
|
# Complete Render
|
|
# =========================================================================
|
|
|
|
def test_complete_render_with_no_tabs(self, tabs_manager):
|
|
"""Test complete render structure when no tabs exist.
|
|
|
|
Why these elements matter:
|
|
- cls mf-tabs-manager: Root container class for styling
|
|
- Controller: HTMX control and state management
|
|
- Header wrapper: Tab buttons container (empty)
|
|
- Content wrapper: Tab content container (shows 'No Content')
|
|
- Script: Initializes tabs on first render
|
|
"""
|
|
render = tabs_manager.render()
|
|
|
|
# Extract main elements
|
|
controller = find_one(render, Div(id=f"{tabs_manager.get_id()}-controller"))
|
|
header = find_one(render, Div(id=f"{tabs_manager.get_id()}-header-wrapper"))
|
|
content = find_one(render, Div(id=f"{tabs_manager.get_id()}-content-wrapper"))
|
|
script = find_one(render, TestScript(f'updateTabs("{tabs_manager.get_id()}-controller")'))
|
|
|
|
assert controller is not None, "Render should contain controller"
|
|
assert header is not None, "Render should contain header wrapper"
|
|
assert content is not None, "Render should contain content wrapper"
|
|
assert script is not None, "Render should contain initialization script"
|
|
|
|
def test_complete_render_with_multiple_tabs(self, tabs_manager):
|
|
"""Test complete render structure with multiple tabs.
|
|
|
|
Why these elements matter:
|
|
- 3 tab buttons: One for each created tab
|
|
- Active tab content visible: Shows current tab content
|
|
- Inactive tabs hidden: Lazy-loaded on activation
|
|
- Structure consistency: All components present
|
|
"""
|
|
tabs_manager.create_tab("Tab1", Div("Content 1"))
|
|
tabs_manager.create_tab("Tab2", Div("Content 2"))
|
|
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
|
|
|
|
render = tabs_manager.render()
|
|
|
|
# Find header
|
|
header_inner = find_one(render, Div(id=f"{tabs_manager.get_id()}-header"))
|
|
|
|
# Should have 3 tab buttons
|
|
tab_buttons = find(header_inner, Div(cls=Contains("mf-tab-button")))
|
|
assert len(tab_buttons) == 3, "Render should contain exactly 3 tab buttons"
|
|
|
|
# Content wrapper should show active tab (tab3)
|
|
active_content = find_one(render, Div(id=f"{tabs_manager.get_id()}-{tab_id3}-content"))
|
|
|
|
expected = Div(Div("Content 3"), cls=Contains("mf-tab-content"))
|
|
assert matches(active_content, expected), "Active tab content should be visible"
|