blob: e7f36f522c13b9e8da792f0d2237a02235b27c94 [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for the cpu diagnostic analysis plugin."""
import unittest
from typing import Any
from unittest.mock import MagicMock, patch
from cpu import (
CpuPlugin,
_analyze_core_utilization_from_average,
_analyze_core_utilization_from_counters,
)
from plugins import PluginArgumentError
class CpuPluginTest(unittest.TestCase):
"""Tests for the cpu analysis plugin using mock trace processor queries."""
def setUp(self) -> None:
self.all_tables = {
"trace_bounds",
"thread_state",
"thread",
"process",
"slice",
"thread_track",
"counter",
"process_counter_track",
}
def test_analyze_success_flow(self) -> None:
"""Tests that all 6 diagnostic sections are executed in prioritized order with expected outputs."""
plugin = CpuPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "wakeup_count" in sql:
return [
{
"thread_name": "idle",
"tid": 0,
"process_name": "idle",
"pid": 0,
"wakeup_count": 5000,
"avg_duration_ms": 0.85,
},
{
"thread_name": "worker_thread",
"tid": 101,
"process_name": "app.cm",
"pid": 42,
"wakeup_count": 1200,
"avg_duration_ms": 0.05,
},
]
elif "Processing Rate" in sql or "effective_load_pct" in sql:
return [
{
"cpu": 0,
"running_ms": 150.0,
"running_pct": 1.5,
"avg_rate_pct": 52.4,
"min_rate_pct": 40.0,
"max_rate_pct": 100.0,
"effective_load_pct": 0.79,
}
]
elif "total_cpu_ms" in sql and "thread_state" in sql:
return [
{
"thread_name": "heavy_worker",
"tid": 201,
"process_name": "service.cm",
"pid": 50,
"total_cpu_ms": 320.5,
}
]
elif "slice_count" in sql:
return [
{
"thread_name": "async_executor",
"tid": 301,
"slice_count": 450,
"total_duration_ms": 42.1,
}
]
elif "binder transaction" in sql:
return [
{
"thread_name": "binder:401",
"tid": 401,
"process_name": "android.service",
"pid": 80,
"transaction_count": 85,
"total_duration_ms": 15.4,
"max_duration_ms": 2.1,
}
]
elif "wake_lease" in sql or "suspend" in sql:
return [
{
"event_name": "acquire_wake_lease:power",
"thread_name": "sag_thread",
"process_name": "system-activity-governor.cm",
"occurrences": 12,
"total_duration_ms": 55.0,
"max_duration_ms": 10.0,
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(["--limit", "10"], "dummy_trace")
self.assertEqual(len(results), 6)
# 1. Restless Sleepers (Wakeups)
self.assertEqual(
results[0]["name"], "Restless Sleepers (Wakeup Counts)"
)
self.assertEqual(len(results[0]["results"]), 2)
self.assertEqual(results[0]["results"][0]["thread_name"], "idle")
self.assertEqual(results[0]["results"][1]["wakeup_count"], 1200)
# 2. Per-Core Utilization & Processing Rate
self.assertEqual(
results[1]["name"], "Per-Core Utilization & Processing Rate"
)
self.assertEqual(len(results[1]["results"]), 1)
self.assertEqual(results[1]["results"][0]["cpu"], 0)
self.assertEqual(results[1]["results"][0]["avg_rate_pct"], 52.4)
# 3. Top CPU Consumers
self.assertEqual(
results[2]["name"], "Top CPU Consumers (Usual Suspects)"
)
self.assertEqual(len(results[2]["results"]), 1)
self.assertEqual(results[2]["results"][0]["total_cpu_ms"], 320.5)
# 4. Fuchsia Async Executor Overhead
self.assertEqual(results[3]["name"], "Fuchsia Async Executor Overhead")
self.assertEqual(len(results[3]["results"]), 1)
self.assertEqual(results[3]["results"][0]["slice_count"], 450)
# 5. Binder IPC Breakdown
self.assertEqual(results[4]["name"], "Binder IPC Breakdown")
self.assertEqual(len(results[4]["results"]), 1)
self.assertEqual(results[4]["results"][0]["transaction_count"], 85)
# 6. Suspend and Wake Lease Tracking
self.assertEqual(results[5]["name"], "Suspend and Wake Lease Tracking")
self.assertEqual(len(results[5]["results"]), 1)
self.assertEqual(
results[5]["results"][0]["event_name"], "acquire_wake_lease:power"
)
def test_analyze_empty_results(self) -> None:
"""Tests that empty query results format as empty lists across all 6 sections."""
plugin = CpuPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
mock_tp.run_query.return_value = []
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 6)
for section in results:
self.assertIn("name", section)
self.assertEqual(section["results"], [])
def test_analyze_missing_schema(self) -> None:
"""Tests that missing schema tables are handled gracefully with section error messages."""
plugin = CpuPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = set()
mock_tp.run_query.return_value = []
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 6)
for section in results:
self.assertIn("error", section)
self.assertIn(
"Required schema tables/views missing", section["error"]
)
def test_analyze_without_frequency_counters(self) -> None:
"""Tests that Query 2 falls back to core utilization when frequency counters are absent."""
plugin = CpuPlugin()
mock_tp = MagicMock()
# All required tables except counter and process_counter_track
tables_no_counters = {
"trace_bounds",
"thread_state",
"thread",
"process",
"slice",
"thread_track",
}
mock_tp.get_tables.return_value = tables_no_counters
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "running_pct" in sql:
self.assertNotIn("process_counter_track", sql)
return [
{
"cpu": 0,
"running_ms": 100.0,
"running_pct": 2.0,
"avg_rate_pct": None,
"min_rate_pct": None,
"max_rate_pct": None,
"effective_load_pct": 2.0,
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 6)
core_util_section = results[1]
self.assertEqual(
core_util_section["name"], "Per-Core Utilization & Processing Rate"
)
self.assertIn("results", core_util_section)
self.assertEqual(len(core_util_section["results"]), 1)
self.assertIsNone(core_util_section["results"][0]["avg_rate_pct"])
def test_analyze_query_failure(self) -> None:
"""Tests that runtime SQL query failures return formatted error dicts without crashing."""
plugin = CpuPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
mock_tp.run_query.side_effect = RuntimeError(
"database disk image is malformed"
)
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 6)
for section in results:
self.assertIn("error", section)
self.assertIn(
"Query execution failed: database disk image is malformed",
section["error"],
)
def test_analyze_core_utilization_variants_directly(self) -> None:
"""Tests calling from_counters and from_average variants directly."""
mock_tp = MagicMock()
mock_tp.run_query.return_value = [
{
"cpu": 0,
"running_ms": 100.0,
"running_pct": 2.0,
"avg_rate_pct": 50.0,
"min_rate_pct": 40.0,
"max_rate_pct": 60.0,
"effective_load_pct": 1.0,
}
]
res_counters = _analyze_core_utilization_from_counters(
mock_tp, self.all_tables
)
self.assertEqual(
res_counters["name"], "Per-Core Utilization & Processing Rate"
)
self.assertEqual(len(res_counters["results"]), 1)
res_average = _analyze_core_utilization_from_average(
mock_tp, self.all_tables
)
self.assertEqual(
res_average["name"], "Per-Core Utilization & Processing Rate"
)
self.assertEqual(len(res_average["results"]), 1)
def test_analyze_with_counter_tables_but_no_power_tracks(self) -> None:
"""Tests that from_average is used when counter tables exist but no kernel:power tracks exist."""
plugin = CpuPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = self.all_tables
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "LIMIT 1" in sql and "Processing Rate:CPU:%" in sql:
# Probe query finds no kernel:power tracks
return []
if "running_pct" in sql:
self.assertNotIn("process_counter_track", sql)
return [
{
"cpu": 0,
"running_ms": 100.0,
"running_pct": 2.0,
"avg_rate_pct": None,
"min_rate_pct": None,
"max_rate_pct": None,
"effective_load_pct": 2.0,
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("cpu.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze([], "dummy_trace")
self.assertEqual(len(results), 6)
core_util_section = results[1]
self.assertEqual(
core_util_section["name"], "Per-Core Utilization & Processing Rate"
)
self.assertIn("results", core_util_section)
self.assertEqual(len(core_util_section["results"]), 1)
self.assertIsNone(core_util_section["results"][0]["avg_rate_pct"])
def test_analyze_invalid_limit(self) -> None:
"""Tests that invalid, non-positive, or non-integer limits raise PluginArgumentError."""
plugin = CpuPlugin()
for invalid_limit in ["0", "-5"]:
with self.subTest(limit=invalid_limit):
with self.assertRaisesRegex(
PluginArgumentError, "Limit must be a positive integer"
):
plugin.analyze(["--limit", invalid_limit], "dummy_trace")
with self.assertRaisesRegex(PluginArgumentError, "invalid int value"):
plugin.analyze(["--limit", "abc"], "dummy_trace")
if __name__ == "__main__":
unittest.main()