feat(#54): 性能预算与懒加载策略引擎(首屏≤2s,图表懒加载)

纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」性能口径:

- perf_budget.py:PerformanceBudget(首屏 2000ms + 6 类资源预算)、
  ResourceMeasurement(block_render 区分关键路径)、BudgetVerifier
  (阻塞串行+非阻塞取最大口径,逐类型比对预算,PASS/FAIL/EMPTY +
  类型化优化建议,reason 可解释)。
- lazy_load.py:LazyLoadStrategy(immediate/visible/idle/never)、
  LazyLoadPolicy(视口行数+预加载+分页阈值+虚拟滚动)、LazyLoadPlanner
  (widget 坐标+滚动位置 → 决策+分页计划+虚拟滚动窗口)。
- tests:32 用例覆盖预算构造/查询、关键路径口径、达标/超预算场景、
  always_load 强制加载、视口边界、分页/虚拟滚动、输入校验。
- _sanity_check.py:冒烟验证达标/超预算/懒加载分流。
This commit is contained in:
2026-08-05 05:26:09 +08:00
parent 2afea16005
commit 5da367dfd8
8 changed files with 1286 additions and 0 deletions
@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""测试引导:把 ``templates/ti-cl4/perf-budget``(目录名含连字符)挂载为
可导入包 ``perf_budget``(与 core 模块测试引导同款模式)。
本模块零内核依赖(纯标准库),仅挂载自身包。
"""
import importlib.util
import os
import sys
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _load_package(name: str, path: str) -> None:
"""按文件路径完整加载一个包(执行其 __init__.py)。"""
if name in sys.modules:
return
init_py = os.path.join(path, "__init__.py")
spec = importlib.util.spec_from_file_location(
name, init_py, submodule_search_locations=[path])
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
_load_package("perf_budget", PKG_DIR)
@@ -0,0 +1,185 @@
# -*- coding: utf-8 -*-
"""图表懒加载策略引擎测试(Issue #54)。
覆盖:
1. 默认策略构造(首屏 4 行视口 + 1 行预加载);
2. 视口内 widget → IMMEDIATE;
3. always_load widget 强制 IMMEDIATE(即使视口外);
4. 视口外 widget → IDLE;
5. 分页加载(视口外超过阈值);
6. 虚拟滚动窗口计算;
7. in_viewport 边界(预加载边距);
8. 边界/错误:负 h、负 scroll_y、负视口参数;
9. 空输入。
"""
import unittest
import _bootstrap # noqa: F401 (挂载 perf_budget 包)
from perf_budget.lazy_load import (
LazyLoadDecision,
LazyLoadError,
LazyLoadPlan,
LazyLoadPlanner,
LazyLoadPolicy,
LazyLoadStrategy,
WidgetLayout,
)
class TestPolicyModel(unittest.TestCase):
"""懒加载策略规则模型。"""
def test_default_cockpit_policy(self):
p = LazyLoadPolicy.default_cockpit()
self.assertEqual(p.first_viewport_rows, 4.0)
self.assertEqual(p.preload_rows, 1.0)
self.assertTrue(p.virtual_scroll)
def test_negative_viewport_rejected(self):
with self.assertRaises(LazyLoadError):
LazyLoadPolicy(first_viewport_rows=0)
def test_negative_preload_rejected(self):
with self.assertRaises(LazyLoadError):
LazyLoadPolicy(preload_rows=-1)
class TestWidgetLayoutValidation(unittest.TestCase):
"""widget 布局边界/错误。"""
def test_non_positive_h_rejected(self):
with self.assertRaises(LazyLoadError):
WidgetLayout(widget_id="w", kind="trend", y=0, h=0)
def test_negative_y_rejected(self):
with self.assertRaises(LazyLoadError):
WidgetLayout(widget_id="w", kind="trend", y=-1)
def test_empty_id_rejected(self):
with self.assertRaises(LazyLoadError):
WidgetLayout(widget_id="", kind="trend", y=0)
class TestPlannerViewport(unittest.TestCase):
"""视口判定与 immediate 决策。"""
def setUp(self):
self.planner = LazyLoadPlanner()
def test_in_viewport_widget_is_immediate(self):
# widget 在首屏视口内(y=0, h=1)→ IMMEDIATE
ws = [WidgetLayout("proc", "process_view", y=0, h=4, always_load=False)]
plan = self.planner.plan(ws, scroll_y_rows=0)
self.assertEqual(plan.decisions[0].strategy, LazyLoadStrategy.IMMEDIATE)
self.assertTrue(plan.decisions[0].in_viewport)
def test_in_viewport_boundary_with_preload(self):
# 视口 [0,4] + 预加载 1 → 视口上界 -1,下界 5
# widget y=4.5,h=1(区间 4.5~5.5)与视口 [−1,5] 相交 → 在视口
w = WidgetLayout("edge", "trend", y=4.5, h=1)
self.assertTrue(self.planner.in_viewport(w, scroll_y_rows=0))
# widget y=5.5,h=1(区间 5.5~6.5)与 [−1,5] 不相交 → 视口外
w2 = WidgetLayout("far", "trend", y=5.5, h=1)
self.assertFalse(self.planner.in_viewport(w2, scroll_y_rows=0))
class TestPlannerAlwaysLoad(unittest.TestCase):
"""always_load 强制首屏立即加载。"""
def test_always_load_forces_immediate_even_out_of_viewport(self):
planner = LazyLoadPlanner()
# widget 在视口外(y=10),但 always_load=True → IMMEDIATE
ws = [WidgetLayout("main", "process_view", y=10, h=4, always_load=True)]
plan = planner.plan(ws, scroll_y_rows=0)
self.assertEqual(plan.decisions[0].strategy, LazyLoadStrategy.IMMEDIATE)
self.assertFalse(plan.decisions[0].in_viewport)
self.assertIn("always_load", plan.decisions[0].reason)
class TestPlannerIdleAndPagination(unittest.TestCase):
"""视口外 IDLE + 分页加载。"""
def test_out_of_viewport_idle_below_threshold(self):
# 视口外 widget 数 < 阈值(6)→ IDLE 不分页
planner = LazyLoadPlanner()
ws = [WidgetLayout(f"w{i}", "trend", y=10 + i, h=1)
for i in range(3)]
plan = planner.plan(ws, scroll_y_rows=0)
for d in plan.decisions:
self.assertEqual(d.strategy, LazyLoadStrategy.IDLE)
self.assertIsNone(d.page)
def test_out_of_viewport_pagination_above_threshold(self):
# 视口外 widget 数 > 阈值(6)→ 分页(page_size=4)
planner = LazyLoadPlanner()
ws = [WidgetLayout(f"w{i}", "trend", y=10 + i, h=1)
for i in range(9)]
plan = planner.plan(ws, scroll_y_rows=0)
idle = [d for d in plan.decisions if d.strategy is LazyLoadStrategy.IDLE]
self.assertEqual(len(idle), 9)
# 9 个 widget / page_size 4 → 3 页
self.assertEqual(len(plan.pages), 3)
self.assertEqual(plan.pages[1], ["w0", "w1", "w2", "w3"])
self.assertEqual(plan.pages[3], ["w8"])
# 每个决策都有 page 号
for d in idle:
self.assertIsNotNone(d.page)
class TestVirtualScroll(unittest.TestCase):
"""虚拟滚动窗口。"""
def test_virtual_window_when_enabled(self):
planner = LazyLoadPlanner() # virtual_scroll=True 默认
plan = planner.plan(
[WidgetLayout("w", "trend", y=0, h=2)], scroll_y_rows=0)
self.assertIsNotNone(plan.virtual_window)
# scroll_y=0 → window [max(0,-1)=0, 0+4+1=5]
self.assertEqual(plan.virtual_window, (0.0, 5.0))
def test_no_virtual_window_when_disabled(self):
policy = LazyLoadPolicy(virtual_scroll=False)
planner = LazyLoadPlanner(policy)
plan = planner.plan(
[WidgetLayout("w", "trend", y=0, h=2)], scroll_y_rows=0)
self.assertIsNone(plan.virtual_window)
class TestPlanAccessorsAndErrors(unittest.TestCase):
"""计划访问器 + 输入校验。"""
def test_plan_accessors(self):
planner = LazyLoadPlanner()
ws = [
WidgetLayout("vp", "trend", y=0, h=1), # immediate
WidgetLayout("out", "trend", y=10, h=1), # idle
]
plan = planner.plan(ws, scroll_y_rows=0)
self.assertEqual(plan.immediate_ids, ["vp"])
self.assertEqual(plan.idle_ids, ["out"])
self.assertEqual(plan.never_ids, [])
def test_negative_scroll_rejected(self):
planner = LazyLoadPlanner()
with self.assertRaises(LazyLoadError):
planner.plan([WidgetLayout("w", "trend", y=0, h=1)], scroll_y_rows=-1)
def test_empty_widgets_returns_empty_plan(self):
planner = LazyLoadPlanner()
plan = planner.plan([], scroll_y_rows=0)
self.assertEqual(plan.decisions, [])
self.assertIn("无 widget", plan.reason)
def test_plan_to_dict(self):
planner = LazyLoadPlanner()
plan = planner.plan(
[WidgetLayout("w", "trend", y=0, h=1)], scroll_y_rows=0)
d = plan.to_dict()
self.assertIn("decisions", d)
self.assertIn("counts", d)
self.assertEqual(d["counts"]["immediate"], 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,186 @@
# -*- 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()