256 lines
10 KiB
Python
256 lines
10 KiB
Python
import shutil
|
|
from datetime import datetime
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fasthtml.common import Div, Span
|
|
|
|
from myfasthtml.controls.Panel import Panel
|
|
from myfasthtml.controls.Profiler import Profiler
|
|
from myfasthtml.core.instances import InstancesManager
|
|
from myfasthtml.core.profiler import profiler, ProfilingTrace
|
|
from myfasthtml.test.matcher import matches, find, Contains, TestIcon, DoesNotContain, And, TestObject
|
|
|
|
|
|
def make_trace(
|
|
command_name: str = "TestCommand",
|
|
duration_ms: float = 50.0,
|
|
trace_id: str = None,
|
|
) -> ProfilingTrace:
|
|
"""Create a fake ProfilingTrace for testing purposes."""
|
|
return ProfilingTrace(
|
|
command_name=command_name,
|
|
command_description=f"{command_name} description",
|
|
command_id=str(uuid4()),
|
|
kwargs={},
|
|
timestamp=datetime.now(),
|
|
total_duration_ms=duration_ms,
|
|
trace_id=trace_id or str(uuid4()),
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_profiler():
|
|
"""Reset profiler singleton state before and after each test."""
|
|
profiler.clear()
|
|
profiler.enabled = False
|
|
yield
|
|
profiler.clear()
|
|
profiler.enabled = False
|
|
|
|
|
|
class TestProfilerBehaviour:
|
|
"""Tests for Profiler control behavior and logic."""
|
|
|
|
@pytest.fixture
|
|
def profiler_control(self, root_instance):
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
ctrl = Profiler(root_instance)
|
|
yield ctrl
|
|
InstancesManager.reset()
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
|
|
def test_i_can_create_profiler(self, profiler_control):
|
|
"""Test that Profiler initializes with no trace selected."""
|
|
assert profiler_control._selected_id is None
|
|
|
|
@pytest.mark.parametrize("initial", [
|
|
False,
|
|
True,
|
|
])
|
|
def test_i_can_toggle_enable(self, profiler_control, initial):
|
|
"""Test that handle_toggle_enable inverts profiler.enabled."""
|
|
profiler.enabled = initial
|
|
profiler_control.handle_toggle_enable()
|
|
assert profiler.enabled == (not initial)
|
|
|
|
def test_i_can_add_traces(self, profiler_control):
|
|
trace_a = make_trace("CommandA", 30.0)
|
|
trace_b = make_trace("CommandB", 60.0)
|
|
profiler._traces.appendleft(trace_a)
|
|
profiler._traces.appendleft(trace_b)
|
|
|
|
assert len(profiler.traces) == 2
|
|
assert profiler.traces == [trace_b, trace_a]
|
|
|
|
def test_i_can_clear_traces_via_handler(self, profiler_control):
|
|
"""Test that handle_clear_traces empties the profiler trace buffer."""
|
|
profiler._traces.appendleft(make_trace())
|
|
profiler_control.handle_clear_traces()
|
|
assert len(profiler.traces) == 0
|
|
|
|
def test_i_can_select_trace_by_id(self, profiler_control):
|
|
"""Test that handle_select_trace stores the given trace_id."""
|
|
trace_id = str(uuid4())
|
|
profiler_control.handle_select_trace(trace_id)
|
|
assert profiler_control._selected_id == trace_id
|
|
|
|
def test_i_can_select_trace_stable_when_new_trace_added(self, profiler_control):
|
|
"""Test that selection by trace_id remains correct when a new trace is prepended.
|
|
|
|
This validates the fix for the index-shift bug: adding a new trace (appendleft)
|
|
must not affect which row appears selected.
|
|
"""
|
|
trace_a = make_trace("CommandA", 30.0)
|
|
trace_b = make_trace("CommandB", 60.0)
|
|
profiler._traces.appendleft(trace_a)
|
|
profiler._traces.appendleft(trace_b)
|
|
profiler_control.handle_select_trace(trace_a.trace_id)
|
|
|
|
# Add a new trace (simulates a new command executing after selection)
|
|
profiler._traces.appendleft(make_trace("NewCommand", 10.0))
|
|
|
|
# Selection still points to trace_a, unaffected by the new prepended trace
|
|
assert profiler_control._selected_id == trace_a.trace_id
|
|
|
|
@pytest.mark.parametrize("duration_ms, expected_cls", [
|
|
(10.0, "mf-profiler-fast"),
|
|
(50.0, "mf-profiler-medium"),
|
|
(150.0, "mf-profiler-slow"),
|
|
])
|
|
def test_i_can_get_duration_class(self, profiler_control, duration_ms, expected_cls):
|
|
"""Test that _duration_cls returns the correct CSS class for each threshold."""
|
|
assert profiler_control._duration_cls(duration_ms) == expected_cls
|
|
|
|
|
|
class TestProfilerRender:
|
|
"""Tests for Profiler control HTML rendering."""
|
|
|
|
@pytest.fixture
|
|
def profiler_control(self, root_instance):
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
ctrl = Profiler(root_instance)
|
|
yield ctrl
|
|
InstancesManager.reset()
|
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
|
|
|
def test_profiler_renders_global_structure(self, profiler_control):
|
|
"""Test that Profiler renders with correct global structure.
|
|
|
|
Why these elements matter:
|
|
- id: Required for HTMX targeting (all commands target this id)
|
|
- cls Contains "mf-profiler": Root CSS class for layout and styling
|
|
- toolbar Div: Always present, contains control actions
|
|
- Panel: Always present, hosts trace list and detail panels
|
|
"""
|
|
html = profiler_control.render()
|
|
expected = Div(
|
|
Div(cls=Contains("mf-profiler-toolbar")), # toolbar
|
|
TestObject(Panel), # panel
|
|
id=profiler_control.get_id(),
|
|
cls=Contains("mf-profiler"),
|
|
)
|
|
assert matches(html, expected)
|
|
|
|
def test_i_can_render_toolbar_when_enabled(self, profiler_control):
|
|
"""Test that toolbar shows pause icon when profiler is enabled.
|
|
|
|
Why these elements matter:
|
|
- pause icon: visual indicator that profiler is actively recording
|
|
"""
|
|
profiler.enabled = True
|
|
toolbar = profiler_control._mk_toolbar()
|
|
assert matches(toolbar, Div(TestIcon("pause_circle20_regular")))
|
|
|
|
def test_i_can_render_toolbar_when_disabled(self, profiler_control):
|
|
"""Test that toolbar shows play icon when profiler is disabled.
|
|
|
|
Why these elements matter:
|
|
- play icon: visual indicator that profiler is stopped and ready to record
|
|
"""
|
|
profiler.enabled = False
|
|
toolbar = profiler_control._mk_toolbar()
|
|
assert matches(toolbar, Div(TestIcon("play_circle20_regular")))
|
|
|
|
def test_i_can_render_toolbar_clear_button(self, profiler_control):
|
|
"""Test that toolbar contains exactly one danger-styled clear button.
|
|
|
|
Why these elements matter:
|
|
- cls Contains "mf-profiler-btn-danger": Ensures the clear button is visually
|
|
distinct (red) to warn the user before clearing all traces
|
|
"""
|
|
toolbar = profiler_control._mk_toolbar()
|
|
danger_buttons = find(toolbar, Div(cls=Contains("mf-profiler-btn-danger")))
|
|
assert len(danger_buttons) == 1, "Toolbar should contain exactly one danger-styled button"
|
|
|
|
def test_i_can_render_empty_trace_list(self, profiler_control):
|
|
"""Test that an empty-state message is shown when no traces are recorded.
|
|
|
|
Why these elements matter:
|
|
- "No traces recorded.": User-facing feedback when profiler has no data
|
|
- cls Contains "mf-profiler-empty": Applies centered empty-state styling
|
|
"""
|
|
trace_list = profiler_control._mk_trace_list()
|
|
assert matches(trace_list, Div("No traces recorded.", cls=Contains("mf-profiler-empty")))
|
|
|
|
def test_i_can_render_trace_with_name_and_timestamp(self, profiler_control):
|
|
"""Test that a trace row shows command name and formatted timestamp.
|
|
|
|
Why these elements matter:
|
|
- Span.mf-profiler-cmd with command_name: primary identifier for the user
|
|
- Span.mf-profiler-ts with formatted timestamp: helps correlate traces with events
|
|
"""
|
|
trace = make_trace("NavigateCell", 50.0)
|
|
ts_expected = trace.timestamp.strftime("%H:%M:%S.") + f"{trace.timestamp.microsecond // 1000:03d}"
|
|
profiler._traces.appendleft(trace)
|
|
|
|
trace_list = profiler_control._mk_trace_list()
|
|
|
|
cmd_spans = find(trace_list, Span("NavigateCell", cls=Contains("mf-profiler-cmd")))
|
|
assert len(cmd_spans) == 1, "Command name should appear exactly once in the trace list"
|
|
|
|
ts_spans = find(trace_list, Span(ts_expected, cls=Contains("mf-profiler-ts")))
|
|
assert len(ts_spans) == 1, "Formatted timestamp should appear exactly once in the trace list"
|
|
|
|
def test_i_can_render_selected_row_has_selected_class(self, profiler_control):
|
|
"""Test that the selected row carries the selected CSS class.
|
|
|
|
Why these elements matter:
|
|
- cls Contains "mf-profiler-row-selected": Visual highlight of the active trace,
|
|
matched by trace_id (stable identifier) rather than list index
|
|
"""
|
|
trace = make_trace("NavigateCell", 50.0)
|
|
profiler._traces.appendleft(trace)
|
|
profiler_control._selected_id = trace.trace_id
|
|
|
|
trace_list = profiler_control._mk_trace_list()
|
|
selected_rows = find(trace_list, Div(cls=Contains("mf-profiler-row-selected")))
|
|
assert len(selected_rows) == 1, "Exactly one row should carry the selected class"
|
|
|
|
def test_i_can_render_unselected_row_has_no_selected_class(self, profiler_control):
|
|
"""Test that non-selected rows do not carry the selected CSS class.
|
|
|
|
Why these elements matter:
|
|
- cls And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected")):
|
|
Confirms that only the selected trace is highlighted, not its siblings
|
|
"""
|
|
trace_a = make_trace("CommandA", 30.0)
|
|
trace_b = make_trace("CommandB", 80.0)
|
|
profiler._traces.appendleft(trace_a)
|
|
profiler._traces.appendleft(trace_b)
|
|
profiler_control._selected_id = trace_b.trace_id
|
|
|
|
trace_list = profiler_control._mk_trace_list()
|
|
unselected_rows = find(trace_list,
|
|
Div(cls=And(Contains("mf-profiler-row"), DoesNotContain("mf-profiler-row-selected"))))
|
|
assert len(unselected_rows) == 1, "Exactly one row should remain unselected"
|
|
|
|
@pytest.mark.parametrize("duration_ms, expected_cls", [
|
|
(10.0, "mf-profiler-fast"),
|
|
(50.0, "mf-profiler-medium"),
|
|
(150.0, "mf-profiler-slow"),
|
|
])
|
|
def test_i_can_render_duration_color_class(self, profiler_control, duration_ms, expected_cls):
|
|
"""Test that the duration span carries the correct color class per threshold.
|
|
|
|
Why these elements matter:
|
|
- mf-profiler-fast/medium/slow on the duration Span: color-codes performance
|
|
at a glance, consistent with the thresholds defined in _duration_cls()
|
|
"""
|
|
trace = make_trace("TestCommand", duration_ms)
|
|
profiler._traces.appendleft(trace)
|
|
|
|
trace_list = profiler_control._mk_trace_list()
|
|
duration_spans = find(trace_list, Span(cls=Contains(expected_cls)))
|
|
assert len(duration_spans) == 1, f"Expected exactly one span with class '{expected_cls}'"
|