190 lines
7.5 KiB
Python
190 lines
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""性能预算与校验引擎测试(Issue #54)。
|
||
|
||
覆盖:
|
||
1. 默认预算构造(首屏 2000ms + 6 类资源预算);
|
||
2. 关键路径耗时计算(阻塞串行 + 非阻塞取最大);
|
||
3. 达标场景(PASS + 裕量 reason);
|
||
4. 单类超预算(FAIL + overrun 明细 + 类型化建议);
|
||
5. 首屏总耗时超预算(FAIL + 整体建议);
|
||
6. 空测量值(EMPTY);
|
||
7. 边界/错误:负耗时、负预算、负 size、空 name。
|
||
"""
|
||
import os
|
||
import sys
|
||
import unittest
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
import _bootstrap # noqa: F401 (挂载 perf_budget 包)
|
||
|
||
from perf_budget.perf_budget import (
|
||
BudgetError,
|
||
BudgetReport,
|
||
BudgetStatus,
|
||
BudgetVerifier,
|
||
PerformanceBudget,
|
||
ResourceBudget,
|
||
ResourceMeasurement,
|
||
ResourceType,
|
||
)
|
||
from perf_budget import ( # noqa: E402 (验证顶层导出)
|
||
PerformanceBudget as _TopBudget,
|
||
BudgetVerifier as _TopVerifier,
|
||
)
|
||
|
||
|
||
class TestBudgetModel(unittest.TestCase):
|
||
"""性能预算模型构造与查询。"""
|
||
|
||
def test_default_cockpit_budget(self):
|
||
b = PerformanceBudget.default_cockpit()
|
||
self.assertEqual(b.name, "cockpit-default")
|
||
self.assertEqual(b.first_paint_ms, 2000)
|
||
# 6 类资源预算(HTML/CSS/JS/FONT/API/IMAGE)
|
||
self.assertEqual(len(b.by_type), 6)
|
||
self.assertEqual(b.budget_for(ResourceType.JS), 800)
|
||
|
||
def test_budget_for_uncovered_type_is_zero(self):
|
||
b = PerformanceBudget.default_cockpit()
|
||
# OTHER 未声明预算 → 0
|
||
self.assertEqual(b.budget_for(ResourceType.OTHER), 0.0)
|
||
|
||
def test_covered_types_ordered(self):
|
||
b = PerformanceBudget.default_cockpit()
|
||
covered = b.covered_types()
|
||
self.assertIn(ResourceType.HTML, covered)
|
||
self.assertEqual(covered, sorted(covered, key=lambda r: list(ResourceType).index(r)))
|
||
|
||
def test_negative_first_paint_rejected(self):
|
||
with self.assertRaises(BudgetError):
|
||
PerformanceBudget(name="bad", first_paint_ms=0)
|
||
|
||
def test_negative_resource_budget_rejected(self):
|
||
with self.assertRaises(BudgetError):
|
||
ResourceBudget(ResourceType.JS, -1, "x")
|
||
|
||
|
||
class TestVerifierCriticalPath(unittest.TestCase):
|
||
"""关键路径耗时口径:阻塞串行 + 非阻塞取最大。"""
|
||
|
||
def setUp(self):
|
||
self.v = BudgetVerifier(PerformanceBudget.default_cockpit())
|
||
|
||
def test_blocking_serial_accumulation(self):
|
||
# 两个阻塞 JS 各 400ms → 关键路径串行 800ms(≤ 预算)
|
||
ms = [
|
||
ResourceMeasurement("app.js", ResourceType.JS, 400, block_render=True),
|
||
ResourceMeasurement("chart.js", ResourceType.JS, 400, block_render=True),
|
||
]
|
||
report = self.v.verify(ms)
|
||
self.assertEqual(report.status, BudgetStatus.PASS)
|
||
# JS 阻塞总和 800ms(恰好等于预算,不超)
|
||
self.assertEqual(report.by_type_actual[ResourceType.JS], 800)
|
||
|
||
def test_non_blocking_takes_max(self):
|
||
# 非阻塞资源并行:取最大值(300),不累加(500)
|
||
ms = [
|
||
ResourceMeasurement("a.js", ResourceType.JS, 300, block_render=False),
|
||
ResourceMeasurement("b.js", ResourceType.JS, 200, block_render=False),
|
||
]
|
||
report = self.v.verify(ms)
|
||
# 非阻塞 JS 最大值 300ms ≤ JS 预算 800 → PASS
|
||
self.assertEqual(report.status, BudgetStatus.PASS)
|
||
self.assertEqual(report.first_paint_ms, 300)
|
||
|
||
|
||
class TestVerifierPassAndFail(unittest.TestCase):
|
||
"""达标 / 不达标场景。"""
|
||
|
||
def setUp(self):
|
||
self.v = BudgetVerifier(PerformanceBudget.default_cockpit())
|
||
|
||
def test_pass_scenario_with_margin_reason(self):
|
||
ms = [
|
||
ResourceMeasurement("doc.html", ResourceType.HTML, 150, block_render=True),
|
||
ResourceMeasurement("app.css", ResourceType.CSS, 250, block_render=True),
|
||
ResourceMeasurement("app.js", ResourceType.JS, 700, block_render=True),
|
||
ResourceMeasurement("font.woff2", ResourceType.FONT, 150, block_render=False),
|
||
]
|
||
report = self.v.verify(ms)
|
||
self.assertEqual(report.status, BudgetStatus.PASS)
|
||
self.assertTrue(report.passed)
|
||
self.assertIn("裕量", report.reason)
|
||
self.assertEqual(report.overrun, [])
|
||
|
||
def test_single_type_overrun_with_advice(self):
|
||
# JS 超预算:阻塞 950ms > 800ms 预算
|
||
ms = [
|
||
ResourceMeasurement("big.js", ResourceType.JS, 950, block_render=True),
|
||
ResourceMeasurement("app.css", ResourceType.CSS, 200, block_render=True),
|
||
ResourceMeasurement("doc.html", ResourceType.HTML, 100, block_render=True),
|
||
]
|
||
report = self.v.verify(ms)
|
||
self.assertEqual(report.status, BudgetStatus.FAIL)
|
||
self.assertEqual(len(report.overrun), 1)
|
||
ov = report.overrun[0]
|
||
self.assertEqual(ov.resource_type, ResourceType.JS)
|
||
self.assertEqual(ov.actual_ms, 950)
|
||
self.assertEqual(ov.budget_ms, 800)
|
||
self.assertGreater(ov.overrun_ms, 0)
|
||
# JS 建议含"代码分割"
|
||
self.assertIn("代码分割", ov.advice)
|
||
|
||
def test_first_paint_overrun_suggestion(self):
|
||
# 首屏总超预算但各类都没超归口预算(用未声明的 OTHER 类型撑爆总预算)
|
||
# OTHER 不在预算表,故 by_type 不报 overrun,但 first_paint 超总预算
|
||
ms = [
|
||
ResourceMeasurement("doc.html", ResourceType.HTML, 200, block_render=True),
|
||
ResourceMeasurement("app.css", ResourceType.CSS, 300, block_render=True),
|
||
ResourceMeasurement("app.js", ResourceType.JS, 800, block_render=True),
|
||
ResourceMeasurement("font.woff2", ResourceType.FONT, 200, block_render=False),
|
||
ResourceMeasurement("api", ResourceType.API, 300, block_render=False),
|
||
ResourceMeasurement("image", ResourceType.IMAGE, 100, block_render=False),
|
||
# 一个大体积 OTHER 阻塞资源把总耗时推过 2000
|
||
ResourceMeasurement("tracker.js", ResourceType.OTHER, 500, block_render=True),
|
||
]
|
||
report = self.v.verify(ms)
|
||
# 总耗时 = 2400 > 2000
|
||
self.assertGreater(report.first_paint_ms, 2000)
|
||
self.assertEqual(report.status, BudgetStatus.FAIL)
|
||
self.assertIn("首屏", report.reason)
|
||
|
||
def test_empty_measurements(self):
|
||
report = self.v.verify([])
|
||
self.assertEqual(report.status, BudgetStatus.EMPTY)
|
||
self.assertFalse(report.passed)
|
||
|
||
|
||
class TestMeasurementValidation(unittest.TestCase):
|
||
"""测量值边界/错误。"""
|
||
|
||
def test_negative_duration_rejected(self):
|
||
with self.assertRaises(BudgetError):
|
||
ResourceMeasurement("x", ResourceType.JS, -1)
|
||
|
||
def test_negative_size_rejected(self):
|
||
with self.assertRaises(BudgetError):
|
||
ResourceMeasurement("x", ResourceType.JS, 10, size_bytes=-1)
|
||
|
||
def test_empty_name_rejected(self):
|
||
with self.assertRaises(BudgetError):
|
||
ResourceMeasurement("", ResourceType.JS, 10)
|
||
|
||
|
||
class TestReportExport(unittest.TestCase):
|
||
"""报告序列化。"""
|
||
|
||
def test_to_dict_keys(self):
|
||
v = BudgetVerifier(PerformanceBudget.default_cockpit())
|
||
report = v.verify([
|
||
ResourceMeasurement("app.js", ResourceType.JS, 700, block_render=True)])
|
||
d = report.to_dict()
|
||
self.assertIn("status", d)
|
||
self.assertIn("first_paint_ms", d)
|
||
self.assertIn("by_type_actual", d)
|
||
self.assertEqual(d["passed"], True)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|