@@ -367,6 +367,246 @@ def test_process_frames_with_file_samples_dict(self):
367367 self .assertEqual (collector .file_samples ['test.py' ][10 ], 1 )
368368
369369
370+ class TestHeatmapCollectorNavigationButtons (unittest .TestCase ):
371+ """Test navigation button behavior for caller/callee relationships.
372+
373+ For every call stack:
374+ - Root frames (entry points): only DOWN arrow (▼) for callees
375+ - Middle frames: both UP (▲) and DOWN (▼) arrows
376+ - Leaf frames: only UP arrow (▲) for callers
377+
378+ All lines within a function should show who calls that function.
379+ """
380+
381+ def setUp (self ):
382+ """Set up test directory and collector."""
383+ self .test_dir = tempfile .mkdtemp ()
384+ self .addCleanup (shutil .rmtree , self .test_dir )
385+
386+ def _create_collector_with_stack (self , frames_list ):
387+ """Helper to create a collector and process frame stacks."""
388+ collector = HeatmapCollector (sample_interval_usec = 100 )
389+ for frames in frames_list :
390+ collector .process_frames (frames , thread_id = 1 )
391+ return collector
392+
393+ def _export_and_get_html (self , collector , filename ):
394+ """Export and return HTML content for a specific file."""
395+ output_path = os .path .join (self .test_dir , 'nav_test' )
396+ with captured_stdout (), captured_stderr ():
397+ collector .export (output_path )
398+
399+ # Find the HTML file for the given source file
400+ for html_file in os .listdir (output_path ):
401+ if html_file .startswith ('file_' ) and html_file .endswith ('.html' ):
402+ html_path = os .path .join (output_path , html_file )
403+ with open (html_path , 'r' , encoding = 'utf-8' ) as f :
404+ content = f .read ()
405+ if filename in content :
406+ return content
407+ return None
408+
409+ def test_leaf_frame_has_only_caller_button (self ):
410+ """Test that leaf frames (top of stack) only have caller buttons."""
411+ # Create a simple call stack: caller.py:10 -> leaf.py:5
412+ # leaf.py:5 is the leaf (where execution happens)
413+ frames = [
414+ ('leaf.py' , (5 , 5 , - 1 , - 1 ), 'leaf_func' , None ), # Leaf
415+ ('caller.py' , (10 , 10 , - 1 , - 1 ), 'caller_func' , None ) # Caller
416+ ]
417+ collector = self ._create_collector_with_stack ([frames ])
418+
419+ # Check that leaf has callers recorded but no callees
420+ leaf_key = ('leaf.py' , 5 )
421+ caller_key = ('caller.py' , 10 )
422+
423+ # Leaf should be in callers_graph (someone calls it)
424+ self .assertIn (leaf_key , collector .callers_graph )
425+
426+ # Leaf should NOT be in call_graph (it doesn't call anyone)
427+ self .assertNotIn (leaf_key , collector .call_graph )
428+
429+ # Caller should be in call_graph (it calls the leaf)
430+ self .assertIn (caller_key , collector .call_graph )
431+
432+ def test_root_frame_has_only_callee_button (self ):
433+ """Test that root frames (bottom of stack) only have callee buttons."""
434+ # Create a simple call stack: root.py:20 -> child.py:10
435+ # root.py:20 is the root (entry point, no one calls it)
436+ frames = [
437+ ('child.py' , (10 , 10 , - 1 , - 1 ), 'child_func' , None ), # Child (leaf)
438+ ('root.py' , (20 , 20 , - 1 , - 1 ), 'root_func' , None ) # Root
439+ ]
440+ collector = self ._create_collector_with_stack ([frames ])
441+
442+ root_key = ('root.py' , 20 )
443+ child_key = ('child.py' , 10 )
444+
445+ # Root should be in call_graph (it calls the child)
446+ self .assertIn (root_key , collector .call_graph )
447+
448+ # Root should NOT be in callers_graph (no one calls it in this profile)
449+ self .assertNotIn (root_key , collector .callers_graph )
450+
451+ # Child's definition line should be in callers_graph
452+ child_def_key = ('child.py' , 10 )
453+ self .assertIn (child_def_key , collector .callers_graph )
454+
455+ def test_middle_frame_has_both_buttons (self ):
456+ """Test that middle frames have both caller and callee buttons."""
457+ # Create a 3-level stack: root.py:30 -> middle.py:20 -> leaf.py:10
458+ frames = [
459+ ('leaf.py' , (10 , 10 , - 1 , - 1 ), 'leaf_func' , None ),
460+ ('middle.py' , (20 , 20 , - 1 , - 1 ), 'middle_func' , None ),
461+ ('root.py' , (30 , 30 , - 1 , - 1 ), 'root_func' , None )
462+ ]
463+ collector = self ._create_collector_with_stack ([frames ])
464+
465+ middle_key = ('middle.py' , 20 )
466+
467+ # Middle should be in call_graph (it calls leaf)
468+ self .assertIn (middle_key , collector .call_graph )
469+
470+ # Middle's definition should be in callers_graph (root calls it)
471+ self .assertIn (middle_key , collector .callers_graph )
472+
473+ def test_all_lines_in_function_see_callers (self ):
474+ """Test that all lines in a function show who calls the function."""
475+ # Simulate a function with multiple sampled lines
476+ # Stack 1: caller.py:100 calls func at line 10, executing at line 12
477+ frames1 = [
478+ ('module.py' , (12 , 12 , - 1 , - 1 ), 'my_func' , None ), # Interior line
479+ ('caller.py' , (100 , 100 , - 1 , - 1 ), 'caller' , None )
480+ ]
481+ # Stack 2: same caller, function executing at line 15
482+ frames2 = [
483+ ('module.py' , (15 , 15 , - 1 , - 1 ), 'my_func' , None ), # Different interior line
484+ ('caller.py' , (100 , 100 , - 1 , - 1 ), 'caller' , None )
485+ ]
486+ # Stack 3: function definition line
487+ frames3 = [
488+ ('module.py' , (10 , 10 , - 1 , - 1 ), 'my_func' , None ), # Definition line
489+ ('caller.py' , (100 , 100 , - 1 , - 1 ), 'caller' , None )
490+ ]
491+
492+ collector = self ._create_collector_with_stack ([frames1 , frames2 , frames3 ])
493+
494+ # All lines should map to the same function
495+ self .assertEqual (collector .line_to_function [('module.py' , 10 )], 'my_func' )
496+ self .assertEqual (collector .line_to_function [('module.py' , 12 )], 'my_func' )
497+ self .assertEqual (collector .line_to_function [('module.py' , 15 )], 'my_func' )
498+
499+ # Function definition should have callers recorded
500+ # The first seen line becomes the definition (line 12 in this case)
501+ func_def = collector .function_definitions [('module.py' , 'my_func' )]
502+ func_def_key = ('module.py' , func_def )
503+ self .assertIn (func_def_key , collector .callers_graph )
504+
505+ def test_multiple_callers_recorded (self ):
506+ """Test that multiple callers are recorded for a function."""
507+ # Two different callers call the same function
508+ frames1 = [
509+ ('target.py' , (10 , 10 , - 1 , - 1 ), 'target_func' , None ),
510+ ('caller1.py' , (20 , 20 , - 1 , - 1 ), 'caller1' , None )
511+ ]
512+ frames2 = [
513+ ('target.py' , (10 , 10 , - 1 , - 1 ), 'target_func' , None ),
514+ ('caller2.py' , (30 , 30 , - 1 , - 1 ), 'caller2' , None )
515+ ]
516+
517+ collector = self ._create_collector_with_stack ([frames1 , frames2 ])
518+
519+ target_key = ('target.py' , 10 )
520+ callers = collector .callers_graph [target_key ]
521+
522+ # Should have 2 different callers
523+ self .assertEqual (len (callers ), 2 )
524+
525+ # Extract caller files
526+ caller_files = {c [0 ] for c in callers }
527+ self .assertIn ('caller1.py' , caller_files )
528+ self .assertIn ('caller2.py' , caller_files )
529+
530+ def test_multiple_callees_from_same_line (self ):
531+ """Test that multiple callees from the same call site are recorded."""
532+ # Same line calls different functions in different samples
533+ frames1 = [
534+ ('target1.py' , (10 , 10 , - 1 , - 1 ), 'func1' , None ),
535+ ('caller.py' , (20 , 20 , - 1 , - 1 ), 'caller' , None )
536+ ]
537+ frames2 = [
538+ ('target2.py' , (15 , 15 , - 1 , - 1 ), 'func2' , None ),
539+ ('caller.py' , (20 , 20 , - 1 , - 1 ), 'caller' , None )
540+ ]
541+
542+ collector = self ._create_collector_with_stack ([frames1 , frames2 ])
543+
544+ caller_key = ('caller.py' , 20 )
545+ callees = collector .call_graph [caller_key ]
546+
547+ # Should have 2 different callees
548+ self .assertEqual (len (callees ), 2 )
549+
550+ # Extract callee files
551+ callee_files = {c [0 ] for c in callees }
552+ self .assertIn ('target1.py' , callee_files )
553+ self .assertIn ('target2.py' , callee_files )
554+
555+ def test_edge_samples_counted (self ):
556+ """Test that edge samples are counted correctly."""
557+ frames = [
558+ ('callee.py' , (10 , 10 , - 1 , - 1 ), 'callee' , None ),
559+ ('caller.py' , (20 , 20 , - 1 , - 1 ), 'caller' , None )
560+ ]
561+
562+ collector = self ._create_collector_with_stack ([frames , frames , frames ])
563+
564+ # Edge should have 3 samples
565+ caller_key = ('caller.py' , 20 )
566+ callee_key = ('callee.py' , 10 )
567+ edge_key = (caller_key , callee_key )
568+
569+ self .assertEqual (collector .edge_samples [edge_key ], 3 )
570+
571+ def test_deep_call_stack_relationships (self ):
572+ """Test navigation relationships in a deep call stack."""
573+ # 5-level stack: root -> A -> B -> C -> leaf
574+ frames = [
575+ ('leaf.py' , (5 , 5 , - 1 , - 1 ), 'leaf' , None ),
576+ ('c.py' , (10 , 10 , - 1 , - 1 ), 'func_c' , None ),
577+ ('b.py' , (15 , 15 , - 1 , - 1 ), 'func_b' , None ),
578+ ('a.py' , (20 , 20 , - 1 , - 1 ), 'func_a' , None ),
579+ ('root.py' , (25 , 25 , - 1 , - 1 ), 'root' , None ),
580+ ]
581+
582+ collector = self ._create_collector_with_stack ([frames ])
583+
584+ # Root: only callees (calls A), no callers
585+ root_key = ('root.py' , 25 )
586+ self .assertIn (root_key , collector .call_graph )
587+ self .assertNotIn (root_key , collector .callers_graph )
588+
589+ # A: both (called by root, calls B)
590+ a_key = ('a.py' , 20 )
591+ self .assertIn (a_key , collector .call_graph )
592+ self .assertIn (a_key , collector .callers_graph )
593+
594+ # B: both (called by A, calls C)
595+ b_key = ('b.py' , 15 )
596+ self .assertIn (b_key , collector .call_graph )
597+ self .assertIn (b_key , collector .callers_graph )
598+
599+ # C: both (called by B, calls leaf)
600+ c_key = ('c.py' , 10 )
601+ self .assertIn (c_key , collector .call_graph )
602+ self .assertIn (c_key , collector .callers_graph )
603+
604+ # Leaf: only callers (called by C), no callees
605+ leaf_key = ('leaf.py' , 5 )
606+ self .assertNotIn (leaf_key , collector .call_graph )
607+ self .assertIn (leaf_key , collector .callers_graph )
608+
609+
370610class TestHeatmapCollectorExport (unittest .TestCase ):
371611 """Test HeatmapCollector.export() method."""
372612
0 commit comments