Added Profiler control with basic UI

This commit is contained in:
2026-03-22 08:32:40 +01:00
parent 72d6cce6ff
commit b8fd4e5ed1
14 changed files with 3440 additions and 1105 deletions

View File

@@ -0,0 +1,255 @@
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}'"