@@ -275,8 +275,11 @@ def test_collect_with_empty_frames(self):
275275
276276 collector .collect (stack_frames )
277277
278- # Empty frames still count as successful since collect() was called successfully
279- self .assertEqual (collector .successful_samples , 1 )
278+ # Empty frames do NOT count as successful - this is important for
279+ # filtered modes like --mode exception where most samples may have
280+ # no matching data. Only samples with actual frame data are counted.
281+ self .assertEqual (collector .successful_samples , 0 )
282+ self .assertEqual (collector .total_samples , 1 )
280283 self .assertEqual (collector .failed_samples , 0 )
281284
282285 def test_collect_skip_idle_threads (self ):
@@ -321,6 +324,124 @@ def test_collect_multiple_threads(self):
321324 self .assertIn (123 , collector .thread_ids )
322325 self .assertIn (124 , collector .thread_ids )
323326
327+ def test_collect_filtered_mode_percentage_calculation (self ):
328+ """Test that percentages use successful_samples, not total_samples.
329+
330+ This is critical for filtered modes like --mode exception where most
331+ samples may be filtered out at the C level. The percentages should
332+ be relative to samples that actually had frame data, not all attempts.
333+ """
334+ collector = LiveStatsCollector (1000 )
335+
336+ # Simulate 10 samples where only 2 had matching data (e.g., exception mode)
337+ frames_with_data = [MockFrameInfo ("test.py" , 10 , "exception_handler" )]
338+ thread_with_data = MockThreadInfo (123 , frames_with_data )
339+ interpreter_with_data = MockInterpreterInfo (0 , [thread_with_data ])
340+
341+ # Empty thread simulates filtered-out data
342+ thread_empty = MockThreadInfo (456 , [])
343+ interpreter_empty = MockInterpreterInfo (0 , [thread_empty ])
344+
345+ # 2 samples with data
346+ collector .collect ([interpreter_with_data ])
347+ collector .collect ([interpreter_with_data ])
348+
349+ # 8 samples without data (filtered out)
350+ for _ in range (8 ):
351+ collector .collect ([interpreter_empty ])
352+
353+ # Verify counts
354+ self .assertEqual (collector .total_samples , 10 )
355+ self .assertEqual (collector .successful_samples , 2 )
356+
357+ # Build stats and check percentage
358+ stats_list = collector .build_stats_list ()
359+ self .assertEqual (len (stats_list ), 1 )
360+
361+ # The function appeared in 2 out of 2 successful samples = 100%
362+ # NOT 2 out of 10 total samples = 20%
363+ location = ("test.py" , 10 , "exception_handler" )
364+ self .assertEqual (collector .result [location ]["direct_calls" ], 2 )
365+
366+ # Verify the percentage calculation in build_stats_list
367+ # direct_calls / successful_samples * 100 = 2/2 * 100 = 100%
368+ # This would be 20% if using total_samples incorrectly
369+
370+ def test_percentage_values_use_successful_samples (self ):
371+ """Test that percentages are calculated from successful_samples.
372+
373+ This verifies the fix where percentages use successful_samples (samples with
374+ frame data) instead of total_samples (all sampling attempts). Critical for
375+ filtered modes like --mode exception.
376+ """
377+ collector = LiveStatsCollector (1000 )
378+
379+ # Simulate scenario: 100 total samples, only 20 had frame data
380+ collector .total_samples = 100
381+ collector .successful_samples = 20
382+
383+ # Function appeared in 10 out of 20 successful samples
384+ collector .result [("test.py" , 10 , "handler" )] = {
385+ "direct_calls" : 10 ,
386+ "cumulative_calls" : 15 ,
387+ "total_rec_calls" : 0 ,
388+ }
389+
390+ stats_list = collector .build_stats_list ()
391+ self .assertEqual (len (stats_list ), 1 )
392+
393+ stat = stats_list [0 ]
394+ # Calculate expected percentages using successful_samples
395+ expected_sample_pct = stat ["direct_calls" ] / collector .successful_samples * 100
396+ expected_cumul_pct = stat ["cumulative_calls" ] / collector .successful_samples * 100
397+
398+ # Percentage should be 10/20 * 100 = 50%, NOT 10/100 * 100 = 10%
399+ self .assertAlmostEqual (expected_sample_pct , 50.0 )
400+ # Cumulative percentage should be 15/20 * 100 = 75%, NOT 15/100 * 100 = 15%
401+ self .assertAlmostEqual (expected_cumul_pct , 75.0 )
402+
403+ # Verify sorting by percentage works correctly
404+ collector .result [("test.py" , 20 , "other" )] = {
405+ "direct_calls" : 5 , # 25% of successful samples
406+ "cumulative_calls" : 8 ,
407+ "total_rec_calls" : 0 ,
408+ }
409+ collector .sort_by = "sample_pct"
410+ stats_list = collector .build_stats_list ()
411+ # handler (50%) should come before other (25%)
412+ self .assertEqual (stats_list [0 ]["func" ][2 ], "handler" )
413+ self .assertEqual (stats_list [1 ]["func" ][2 ], "other" )
414+
415+ def test_build_stats_list_zero_successful_samples (self ):
416+ """Test build_stats_list handles zero successful_samples without division by zero.
417+
418+ When all samples are filtered out (e.g., exception mode with no exceptions),
419+ percentage calculations should return 0 without raising ZeroDivisionError.
420+ """
421+ collector = LiveStatsCollector (1000 )
422+
423+ # Edge case: data exists but no successful samples
424+ collector .result [("test.py" , 10 , "func" )] = {
425+ "direct_calls" : 10 ,
426+ "cumulative_calls" : 10 ,
427+ "total_rec_calls" : 0 ,
428+ }
429+ collector .total_samples = 100
430+ collector .successful_samples = 0 # All samples filtered out
431+
432+ # Should not raise ZeroDivisionError
433+ stats_list = collector .build_stats_list ()
434+ self .assertEqual (len (stats_list ), 1 )
435+
436+ # Verify percentage-based sorting also works with zero successful_samples
437+ collector .sort_by = "sample_pct"
438+ stats_list = collector .build_stats_list ()
439+ self .assertEqual (len (stats_list ), 1 )
440+
441+ collector .sort_by = "cumul_pct"
442+ stats_list = collector .build_stats_list ()
443+ self .assertEqual (len (stats_list ), 1 )
444+
324445
325446class TestLiveStatsCollectorStatisticsBuilding (unittest .TestCase ):
326447 """Tests for statistics building and sorting."""
@@ -345,6 +466,8 @@ def setUp(self):
345466 "total_rec_calls" : 0 ,
346467 }
347468 self .collector .total_samples = 300
469+ # successful_samples is used for percentage calculations
470+ self .collector .successful_samples = 300
348471
349472 def test_build_stats_list (self ):
350473 """Test that stats list is built correctly."""
0 commit comments