# -*- coding: utf-8 -*- """性能预算与校验引擎测试(Issue #54)。 覆盖: 1. 默认预算构造(首屏 2000ms + 6 类资源预算); 2. 关键路径耗时计算(阻塞串行 + 非阻塞取最大); 3. 达标场景(PASS + 裕量 reason); 4. 单类超预算(FAIL + overrun 明细 + 类型化建议); 5. 首屏总耗时超预算(FAIL + 整体建议); 6. 空测量值(EMPTY); 7. 边界/错误:负耗时、负预算、负 size、空 name。 """ import unittest 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()