@@ -367,6 +367,112 @@ def test_process_frames_with_file_samples_dict(self):
367367 self .assertEqual (collector .file_samples ['test.py' ][10 ], 1 )
368368
369369
370+ def frame (filename , line , func ):
371+ """Create a frame tuple: (filename, location, funcname, opcode)."""
372+ return (filename , (line , line , - 1 , - 1 ), func , None )
373+
374+
375+ class TestHeatmapCollectorNavigationButtons (unittest .TestCase ):
376+ """Test navigation button behavior for caller/callee relationships.
377+
378+ For every call stack:
379+ - Root frames (entry points): only DOWN arrow (callees)
380+ - Middle frames: both UP and DOWN arrows
381+ - Leaf frames: only UP arrow (callers)
382+ """
383+
384+ def collect (self , * stacks ):
385+ """Create collector and process frame stacks."""
386+ collector = HeatmapCollector (sample_interval_usec = 100 )
387+ for stack in stacks :
388+ collector .process_frames (stack , thread_id = 1 )
389+ return collector
390+
391+ def key (self , filename , line ):
392+ """Create a line key tuple."""
393+ return (filename , line )
394+
395+ def assert_has_callers (self , collector , filename , line ):
396+ self .assertIn (self .key (filename , line ), collector .callers_graph )
397+
398+ def assert_no_callers (self , collector , filename , line ):
399+ self .assertNotIn (self .key (filename , line ), collector .callers_graph )
400+
401+ def assert_has_callees (self , collector , filename , line ):
402+ self .assertIn (self .key (filename , line ), collector .call_graph )
403+
404+ def assert_no_callees (self , collector , filename , line ):
405+ self .assertNotIn (self .key (filename , line ), collector .call_graph )
406+
407+ def test_deep_call_stack_relationships (self ):
408+ """Test root/middle/leaf navigation in a 5-level call stack."""
409+ # Stack: root -> A -> B -> C -> leaf
410+ stack = [
411+ frame ('leaf.py' , 5 , 'leaf' ),
412+ frame ('c.py' , 10 , 'func_c' ),
413+ frame ('b.py' , 15 , 'func_b' ),
414+ frame ('a.py' , 20 , 'func_a' ),
415+ frame ('root.py' , 25 , 'root' ),
416+ ]
417+ c = self .collect (stack )
418+
419+ # Root: only callees (no one calls it)
420+ self .assert_has_callees (c , 'root.py' , 25 )
421+ self .assert_no_callers (c , 'root.py' , 25 )
422+
423+ # Middle frames: both callers and callees
424+ for f , l in [('a.py' , 20 ), ('b.py' , 15 ), ('c.py' , 10 )]:
425+ self .assert_has_callees (c , f , l )
426+ self .assert_has_callers (c , f , l )
427+
428+ # Leaf: only callers (doesn't call anyone)
429+ self .assert_no_callees (c , 'leaf.py' , 5 )
430+ self .assert_has_callers (c , 'leaf.py' , 5 )
431+
432+ def test_all_lines_in_function_see_callers (self ):
433+ """Test that interior lines map to their function for caller lookup."""
434+ # Same function sampled at different lines (12, 15, 10)
435+ c = self .collect (
436+ [frame ('mod.py' , 12 , 'my_func' ), frame ('caller.py' , 100 , 'caller' )],
437+ [frame ('mod.py' , 15 , 'my_func' ), frame ('caller.py' , 100 , 'caller' )],
438+ [frame ('mod.py' , 10 , 'my_func' ), frame ('caller.py' , 100 , 'caller' )],
439+ )
440+
441+ # All lines should map to same function
442+ for line in [10 , 12 , 15 ]:
443+ self .assertEqual (c .line_to_function [('mod.py' , line )], 'my_func' )
444+
445+ # Function definition line should have callers
446+ func_def = c .function_definitions [('mod.py' , 'my_func' )]
447+ self .assert_has_callers (c , 'mod.py' , func_def )
448+
449+ def test_multiple_callers_and_callees (self ):
450+ """Test multiple callers/callees are recorded correctly."""
451+ # Two callers -> target, and caller -> two callees
452+ c = self .collect (
453+ [frame ('target.py' , 10 , 'target' ), frame ('caller1.py' , 20 , 'c1' )],
454+ [frame ('target.py' , 10 , 'target' ), frame ('caller2.py' , 30 , 'c2' )],
455+ [frame ('callee1.py' , 5 , 'f1' ), frame ('dispatcher.py' , 40 , 'dispatch' )],
456+ [frame ('callee2.py' , 6 , 'f2' ), frame ('dispatcher.py' , 40 , 'dispatch' )],
457+ )
458+
459+ # Target has 2 callers
460+ callers = c .callers_graph [('target.py' , 10 )]
461+ self .assertEqual ({x [0 ] for x in callers }, {'caller1.py' , 'caller2.py' })
462+
463+ # Dispatcher has 2 callees
464+ callees = c .call_graph [('dispatcher.py' , 40 )]
465+ self .assertEqual ({x [0 ] for x in callees }, {'callee1.py' , 'callee2.py' })
466+
467+ def test_edge_samples_counted (self ):
468+ """Test that repeated calls accumulate edge counts."""
469+ stack = [frame ('callee.py' , 10 , 'callee' ), frame ('caller.py' , 20 , 'caller' )]
470+ c = self .collect (stack , stack , stack )
471+
472+ edge_key = (('caller.py' , 20 ), ('callee.py' , 10 ))
473+ self .assertEqual (c .edge_samples [edge_key ], 3 )
474+
475+
370476class TestHeatmapCollectorExport (unittest .TestCase ):
371477 """Test HeatmapCollector.export() method."""
372478
0 commit comments