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:
@@ -0,0 +1,53 @@
|
||||
# 性能预算与懒加载策略引擎(Issue #54 / PRD 5.5)
|
||||
|
||||
> 父 Issue「⑤ 性能优化(首屏 ≤ 2s,图表懒加载)· 0.5d」
|
||||
|
||||
把驾驶舱性能口径落为**可校验、可测试的纯标准库引擎**(无 node/前端构建环境,
|
||||
与 iAOP 零运行时依赖原则一致)。产出两类资产:
|
||||
|
||||
## 1. 性能预算与校验(`perf_budget.py`)
|
||||
|
||||
- `PerformanceBudget` —— 首屏预算(默认 ≤ 2000ms,PRD 5.5)+ 分资源类型预算
|
||||
(HTML/CSS/JS/字体/接口/图片)。`PerformanceBudget.default_cockpit()` 给驾驶舱默认预算。
|
||||
- `ResourceMeasurement` —— 单资源实测耗时(含 `block_render` 标记,区分关键路径)。
|
||||
- `BudgetVerifier` —— 校验器:按**关键路径口径**(阻塞渲染资源串行累加、非阻塞并行取最大)
|
||||
计算首屏总耗时,逐类型比对预算,产出 `BudgetReport`(状态 PASS/FAIL/EMPTY + 超预算
|
||||
明细 + 类型化优化建议,每条 `reason` 可解释)。
|
||||
|
||||
```python
|
||||
from perf_budget import PerformanceBudget, BudgetVerifier, ResourceMeasurement, ResourceType
|
||||
|
||||
budget = PerformanceBudget.default_cockpit() # 首屏 ≤ 2s
|
||||
report = BudgetVerifier(budget).verify([
|
||||
ResourceMeasurement("app.js", ResourceType.JS, 700, block_render=True),
|
||||
ResourceMeasurement("app.css", ResourceType.CSS, 250, block_render=True),
|
||||
])
|
||||
assert report.passed # PASS + 裕量 reason
|
||||
```
|
||||
|
||||
## 2. 图表懒加载策略(`lazy_load.py`)
|
||||
|
||||
- `LazyLoadStrategy` —— 4 种加载时机:`immediate`(首屏立即)/`visible`(进视口)/
|
||||
`idle`(空闲时)/`never`(不加载)。
|
||||
- `LazyLoadPolicy` —— 策略规则(首屏视口行数、rootMargin 预加载、分页阈值、虚拟滚动)。
|
||||
- `LazyLoadPlanner` —— 决策器:widget 布局坐标 + 视口/滚动位置 → `LazyLoadPlan`
|
||||
(每个 widget 的加载决策 + 分页计划 + 虚拟滚动窗口,`reason` 可解释)。
|
||||
|
||||
```python
|
||||
from perf_budget import LazyLoadPlanner, WidgetLayout
|
||||
|
||||
plan = LazyLoadPlanner().plan([
|
||||
WidgetLayout("proc", "process_view", y=0, h=4, always_load=True),
|
||||
WidgetLayout("trend", "trend", y=10, h=2), # 视口外 → idle
|
||||
], scroll_y_rows=0)
|
||||
print(plan.immediate_ids, plan.idle_ids, plan.pages)
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s templates/ti-cl4/perf-budget/tests -p "test_*.py" -v
|
||||
```
|
||||
|
||||
覆盖正常 + 边界 + 错误(32 用例):预算构造/查询、关键路径串行/并行口径、达标/超预算
|
||||
场景、always_load 强制加载、视口边界、分页/虚拟滚动、输入校验。
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""性能预算与懒加载策略引擎包(Issue #54)。
|
||||
|
||||
对齐 PRD 5.5「⑤ 配置化驾驶舱」性能口径:首屏 ≤ 2s、图表懒加载。
|
||||
"""
|
||||
from .perf_budget import (
|
||||
BudgetError,
|
||||
BudgetOverrun,
|
||||
BudgetReport,
|
||||
BudgetStatus,
|
||||
BudgetVerifier,
|
||||
PerformanceBudget,
|
||||
ResourceBudget,
|
||||
ResourceMeasurement,
|
||||
ResourceType,
|
||||
)
|
||||
from .lazy_load import (
|
||||
LazyLoadDecision,
|
||||
LazyLoadError,
|
||||
LazyLoadPlan,
|
||||
LazyLoadPlanner,
|
||||
LazyLoadPolicy,
|
||||
LazyLoadStrategy,
|
||||
WidgetLayout,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# perf_budget
|
||||
"PerformanceBudget",
|
||||
"ResourceBudget",
|
||||
"ResourceMeasurement",
|
||||
"ResourceType",
|
||||
"BudgetReport",
|
||||
"BudgetOverrun",
|
||||
"BudgetVerifier",
|
||||
"BudgetStatus",
|
||||
"BudgetError",
|
||||
# lazy_load
|
||||
"LazyLoadPolicy",
|
||||
"LazyLoadDecision",
|
||||
"LazyLoadPlan",
|
||||
"LazyLoadStrategy",
|
||||
"LazyLoadPlanner",
|
||||
"LazyLoadError",
|
||||
"WidgetLayout",
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""性能预算与懒加载策略冒烟脚本(Issue #54)。
|
||||
|
||||
直接运行 ``python _sanity_check.py`` 验证:默认预算可构造、校验器对达标/超预算
|
||||
场景给出正确状态、懒加载决策器对视口内外 widget 给出 immediate/idle。零第三方依赖。
|
||||
"""
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
|
||||
# perf-budget 目录名含连字符,按文件路径加载为合法包 perf_budget。
|
||||
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def _load_pkg(name, path):
|
||||
if name in sys.modules:
|
||||
return
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(path, "__init__.py"),
|
||||
submodule_search_locations=[path])
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
|
||||
|
||||
_load_pkg("perf_budget", _PKG_DIR)
|
||||
|
||||
from perf_budget import ( # noqa: E402
|
||||
BudgetStatus, BudgetVerifier, LazyLoadPlanner, PerformanceBudget,
|
||||
ResourceMeasurement, ResourceType, WidgetLayout)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 1) 性能预算:达标场景
|
||||
budget = PerformanceBudget.default_cockpit()
|
||||
report = BudgetVerifier(budget).verify([
|
||||
ResourceMeasurement("app.js", ResourceType.JS, 700, block_render=True),
|
||||
ResourceMeasurement("app.css", ResourceType.CSS, 250, block_render=True),
|
||||
ResourceMeasurement("doc.html", ResourceType.HTML, 150, block_render=True),
|
||||
])
|
||||
assert report.status is BudgetStatus.PASS, report.reason
|
||||
print(f"[OK] 性能预算达标:首屏 {report.first_paint_ms:.0f}ms "
|
||||
f"≤ {report.budget_ms:.0f}ms({report.reason})")
|
||||
|
||||
# 2) 超预算场景
|
||||
bad = BudgetVerifier(budget).verify([
|
||||
ResourceMeasurement("big.js", ResourceType.JS, 950, block_render=True),
|
||||
])
|
||||
assert bad.status is BudgetStatus.FAIL
|
||||
print(f"[OK] 性能预算告警:{bad.overrun[0].reason} → {bad.overrun[0].advice}")
|
||||
|
||||
# 3) 懒加载:视口内外分流
|
||||
plan = LazyLoadPlanner().plan([
|
||||
WidgetLayout("proc", "process_view", y=0, h=4, always_load=True),
|
||||
WidgetLayout("trend", "trend", y=10, h=2),
|
||||
], scroll_y_rows=0)
|
||||
assert plan.immediate_ids == ["proc"]
|
||||
assert plan.idle_ids == ["trend"]
|
||||
print(f"[OK] 懒加载:immediate={plan.immediate_ids} idle={plan.idle_ids}")
|
||||
print("性能预算与懒加载策略冒烟通过 ✅")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,328 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""图表 / Widget 懒加载策略引擎(Issue #54 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||||
|
||||
PRD 5.5 验收口径含「图表懒加载」:首屏只渲染视口(viewport)内的图表,
|
||||
视口外的 widget(趋势/KPI/告警/NL 查询)推迟到滚动可见或空闲时加载,
|
||||
把首屏 JS/接口预算留给工艺流程主视图。本模块把该口径落为**可判定的懒加载
|
||||
策略引擎**——给定 widget 的布局坐标 + 视口尺寸 + 滚动位置,决定每个 widget
|
||||
的加载时机(immediate/visible/idle/never),并产出分页/虚拟滚动计划。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **懒加载策略即规格**(``LazyLoadStrategy``):4 种加载时机:
|
||||
- ``immediate`` 首屏立即加载(工艺流程主视图);
|
||||
- ``visible`` 进入视口时加载(IntersectionObserver 口径);
|
||||
- ``idle`` 浏览器空闲时加载(requestIdleCallback);
|
||||
- ``never`` 不加载(隐藏 tab/折叠面板内的 widget)。
|
||||
2. **策略规则**(``LazyLoadPolicy``):可配置——首屏视口高度、根边距
|
||||
(rootMargin,预加载视口外 N px)、分页阈值(视口外 widget 超过阈值则
|
||||
分页加载)、虚拟滚动开关。
|
||||
3. **决策器**(``LazyLoadPlanner``):对一组 widget(坐标 + 尺寸 + 滚动位置)
|
||||
逐个判定策略,产出 :class:`LazyLoadDecision`(含 reason 可解释)+ 分页计划
|
||||
+ 虚拟滚动窗口。
|
||||
4. **纯标准库**:无依赖,与 iAOP 零运行时依赖原则一致。
|
||||
|
||||
用法::
|
||||
|
||||
policy = LazyLoadPolicy.default_cockpit()
|
||||
plan = LazyLoadPlanner(policy).plan(widgets, viewport_h=900, scroll_y=0)
|
||||
for d in plan.decisions:
|
||||
print(d.widget_id, d.strategy.value, d.reason)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
class LazyLoadError(ValueError):
|
||||
"""懒加载策略/决策错误(坐标非法、视口尺寸非正等)。"""
|
||||
|
||||
|
||||
class LazyLoadStrategy(str, Enum):
|
||||
"""Widget 加载时机(懒加载策略取值)。"""
|
||||
|
||||
IMMEDIATE = "immediate" # 首屏立即加载
|
||||
VISIBLE = "visible" # 进入视口时加载
|
||||
IDLE = "idle" # 浏览器空闲时加载
|
||||
NEVER = "never" # 不加载(隐藏/折叠)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
LazyLoadStrategy.IMMEDIATE: "首屏立即加载",
|
||||
LazyLoadStrategy.VISIBLE: "进入视口加载",
|
||||
LazyLoadStrategy.IDLE: "空闲时加载",
|
||||
LazyLoadStrategy.NEVER: "不加载",
|
||||
}[self]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WidgetLayout:
|
||||
"""单个 widget 的布局坐标(12 列网格 + 行坐标,对齐 cockpit layout v1)。
|
||||
|
||||
Attributes:
|
||||
widget_id: widget 唯一 id(对应布局资产里的 widget)。
|
||||
kind: widget 类型(process_view/trend/kpi_card/alarm_panel/nl_query)。
|
||||
y: 纵向起始行(像素化的行号 × row_height_px 近似视口位置)。
|
||||
h: 纵向占行数(h × row_height_px = 视口内高度 px)。
|
||||
priority: 优先级(0 最高,默认 5;用于 idle 排序)。
|
||||
always_load: 强制立即加载(如工艺流程主视图,覆盖视口判定)。
|
||||
"""
|
||||
|
||||
widget_id: str
|
||||
kind: str
|
||||
y: float
|
||||
h: float = 1.0
|
||||
priority: int = 5
|
||||
always_load: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.widget_id:
|
||||
raise LazyLoadError("WidgetLayout.widget_id 不能为空")
|
||||
if self.h <= 0:
|
||||
raise LazyLoadError(
|
||||
f"widget {self.widget_id!r} h 必须 > 0,实际 {self.h}")
|
||||
if self.y < 0:
|
||||
raise LazyLoadError(
|
||||
f"widget {self.widget_id!r} y 不能为负,实际 {self.y}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class LazyLoadPolicy:
|
||||
"""懒加载策略规则(可配置,换行业只改规则不改前端代码)。
|
||||
|
||||
Attributes:
|
||||
first_viewport_rows: 首屏视口覆盖的行数(视口高度 / 行高,默认 4 行)。
|
||||
preload_rows: rootMargin 预加载视口外的行数(默认 1 行预加载)。
|
||||
idle_batch: 空闲时每批加载的 widget 数(默认 2)。
|
||||
pagination_threshold: 视口外 widget 数超过该阈值启用分页加载(默认 6)。
|
||||
page_size: 分页大小(每页 widget 数,默认 4)。
|
||||
virtual_scroll: 是否启用虚拟滚动(视口外不挂载 DOM)。
|
||||
"""
|
||||
|
||||
first_viewport_rows: float = 4.0
|
||||
preload_rows: float = 1.0
|
||||
idle_batch: int = 2
|
||||
pagination_threshold: int = 6
|
||||
page_size: int = 4
|
||||
virtual_scroll: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.first_viewport_rows <= 0:
|
||||
raise LazyLoadError(
|
||||
f"first_viewport_rows 必须 > 0,实际 {self.first_viewport_rows}")
|
||||
if self.preload_rows < 0:
|
||||
raise LazyLoadError(
|
||||
f"preload_rows 不能为负,实际 {self.preload_rows}")
|
||||
if self.idle_batch <= 0:
|
||||
raise LazyLoadError(
|
||||
f"idle_batch 必须 > 0,实际 {self.idle_batch}")
|
||||
if self.pagination_threshold < 0:
|
||||
raise LazyLoadError(
|
||||
f"pagination_threshold 不能为负,实际 {self.pagination_threshold}")
|
||||
if self.page_size <= 0:
|
||||
raise LazyLoadError(
|
||||
f"page_size 必须 > 0,实际 {self.page_size}")
|
||||
|
||||
@classmethod
|
||||
def default_cockpit(cls) -> "LazyLoadPolicy":
|
||||
"""驾驶舱默认懒加载策略(首屏 4 行视口 + 1 行预加载 + 分页/虚拟滚动)。"""
|
||||
return cls(
|
||||
first_viewport_rows=4.0,
|
||||
preload_rows=1.0,
|
||||
idle_batch=2,
|
||||
pagination_threshold=6,
|
||||
page_size=4,
|
||||
virtual_scroll=True,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LazyLoadDecision:
|
||||
"""单个 widget 的懒加载决策(含 reason 可解释)。"""
|
||||
|
||||
widget_id: str
|
||||
kind: str
|
||||
strategy: LazyLoadStrategy
|
||||
in_viewport: bool
|
||||
page: Optional[int] = None # 分页加载时的页码(从 1 起);None=不分页
|
||||
reason: str = "" # 为何选该策略(事实陈述)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"widget_id": self.widget_id,
|
||||
"kind": self.kind,
|
||||
"strategy": self.strategy.value,
|
||||
"in_viewport": self.in_viewport,
|
||||
"page": self.page,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LazyLoadPlan:
|
||||
"""懒加载计划:全部 widget 决策 + 分页信息 + 虚拟滚动窗口。"""
|
||||
|
||||
decisions: List[LazyLoadDecision] = field(default_factory=list)
|
||||
pages: Dict[int, List[str]] = field(default_factory=dict) # page → widget_ids
|
||||
virtual_window: Optional[Tuple[float, float]] = None # (y_start, y_end)
|
||||
reason: str = ""
|
||||
|
||||
@property
|
||||
def immediate_ids(self) -> List[str]:
|
||||
"""首屏立即加载的 widget id。"""
|
||||
return [d.widget_id for d in self.decisions
|
||||
if d.strategy is LazyLoadStrategy.IMMEDIATE]
|
||||
|
||||
@property
|
||||
def visible_ids(self) -> List[str]:
|
||||
return [d.widget_id for d in self.decisions
|
||||
if d.strategy is LazyLoadStrategy.VISIBLE]
|
||||
|
||||
@property
|
||||
def idle_ids(self) -> List[str]:
|
||||
return [d.widget_id for d in self.decisions
|
||||
if d.strategy is LazyLoadStrategy.IDLE]
|
||||
|
||||
@property
|
||||
def never_ids(self) -> List[str]:
|
||||
return [d.widget_id for d in self.decisions
|
||||
if d.strategy is LazyLoadStrategy.NEVER]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"decisions": [d.to_dict() for d in self.decisions],
|
||||
"pages": {str(p): ids for p, ids in self.pages.items()},
|
||||
"virtual_window": (list(self.virtual_window)
|
||||
if self.virtual_window else None),
|
||||
"reason": self.reason,
|
||||
"counts": {
|
||||
"immediate": len(self.immediate_ids),
|
||||
"visible": len(self.visible_ids),
|
||||
"idle": len(self.idle_ids),
|
||||
"never": len(self.never_ids),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LazyLoadPlanner:
|
||||
"""懒加载决策器:widget 布局 + 视口/滚动 → 加载计划。
|
||||
|
||||
Args:
|
||||
policy: 懒加载策略规则。
|
||||
row_height_px: 行高(px),用于把行号换算为视口像素;默认 120px。
|
||||
"""
|
||||
|
||||
def __init__(self, policy: Optional[LazyLoadPolicy] = None,
|
||||
row_height_px: float = 120.0) -> None:
|
||||
self.policy = policy or LazyLoadPolicy.default_cockpit()
|
||||
if row_height_px <= 0:
|
||||
raise LazyLoadError(f"row_height_px 必须 > 0,实际 {row_height_px}")
|
||||
self.row_height_px = float(row_height_px)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def in_viewport(self, w: WidgetLayout, scroll_y_rows: float) -> bool:
|
||||
"""widget 是否在当前视口(含预加载边距)内。
|
||||
|
||||
视口范围:``[scroll_y - preload, scroll_y + first_viewport + preload]``。
|
||||
"""
|
||||
top = w.y
|
||||
bottom = w.y + w.h
|
||||
vp_top = scroll_y_rows - self.policy.preload_rows
|
||||
vp_bottom = (scroll_y_rows
|
||||
+ self.policy.first_viewport_rows
|
||||
+ self.policy.preload_rows)
|
||||
# 区间相交即视为视口内
|
||||
return not (bottom <= vp_top or top >= vp_bottom)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def plan(self, widgets: Sequence[WidgetLayout],
|
||||
scroll_y_rows: float = 0.0) -> LazyLoadPlan:
|
||||
"""对所有 widget 生成懒加载决策 + 分页/虚拟滚动计划。
|
||||
|
||||
Args:
|
||||
widgets: widget 布局列表(按 y 升序更稳定,但本方法不强求)。
|
||||
scroll_y_rows: 当前滚动位置(以"行"为单位;scroll_y_px/row_height_px)。
|
||||
"""
|
||||
if scroll_y_rows < 0:
|
||||
raise LazyLoadError(f"scroll_y_rows 不能为负,实际 {scroll_y_rows}")
|
||||
if not widgets:
|
||||
return LazyLoadPlan(reason="无 widget,无需懒加载计划")
|
||||
|
||||
policy = self.policy
|
||||
decisions: List[LazyLoadDecision] = []
|
||||
|
||||
# 1) 判定每个 widget:always_load → immediate;在视口 → immediate/visible;
|
||||
# 视口外 → idle/never
|
||||
out_of_viewport: List[WidgetLayout] = []
|
||||
for w in widgets:
|
||||
in_vp = self.in_viewport(w, scroll_y_rows)
|
||||
if w.always_load:
|
||||
decisions.append(LazyLoadDecision(
|
||||
widget_id=w.widget_id, kind=w.kind,
|
||||
strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=in_vp,
|
||||
reason=f"{w.kind} 标记 always_load,强制首屏立即加载"))
|
||||
continue
|
||||
if in_vp:
|
||||
decisions.append(LazyLoadDecision(
|
||||
widget_id=w.widget_id, kind=w.kind,
|
||||
strategy=LazyLoadStrategy.IMMEDIATE, in_viewport=True,
|
||||
reason=f"{w.kind} 位于首屏视口(行 {w.y}~{w.y + w.h}),"
|
||||
f"立即加载"))
|
||||
else:
|
||||
out_of_viewport.append(w)
|
||||
|
||||
# 2) 视口外 widget:视数量决定分页/虚拟滚动
|
||||
use_pagination = (len(out_of_viewport) > policy.pagination_threshold)
|
||||
# 按 y 排序后分页(页内顺序稳定)
|
||||
sorted_out = sorted(out_of_viewport, key=lambda x: (x.y, x.priority))
|
||||
if use_pagination:
|
||||
pages: Dict[int, List[str]] = {}
|
||||
for idx, w in enumerate(sorted_out):
|
||||
page_no = idx // policy.page_size + 1
|
||||
pages.setdefault(page_no, []).append(w.widget_id)
|
||||
decisions.append(LazyLoadDecision(
|
||||
widget_id=w.widget_id, kind=w.kind,
|
||||
strategy=LazyLoadStrategy.IDLE, in_viewport=False,
|
||||
page=page_no,
|
||||
reason=f"{w.kind} 在视口外(行 {w.y}),分页加载第 {page_no} 页,"
|
||||
f"空闲时按 batch={policy.idle_batch} 加载"))
|
||||
else:
|
||||
pages = {}
|
||||
# 视口外但数量少 → idle 加载(不分页)
|
||||
for w in sorted_out:
|
||||
decisions.append(LazyLoadDecision(
|
||||
widget_id=w.widget_id, kind=w.kind,
|
||||
strategy=LazyLoadStrategy.IDLE, in_viewport=False,
|
||||
reason=f"{w.kind} 在视口外(行 {w.y}),数量未达分页阈值"
|
||||
f"({policy.pagination_threshold}),空闲时加载"))
|
||||
|
||||
# 3) 虚拟滚动窗口(仅视口 + 预加载边距内的 widget 挂载 DOM)
|
||||
virtual_window: Optional[Tuple[float, float]] = None
|
||||
if policy.virtual_scroll:
|
||||
vp_top = max(0.0, scroll_y_rows - policy.preload_rows)
|
||||
vp_bottom = (scroll_y_rows
|
||||
+ policy.first_viewport_rows
|
||||
+ policy.preload_rows)
|
||||
virtual_window = (vp_top, vp_bottom)
|
||||
|
||||
reason_parts = [
|
||||
f"视口 {policy.first_viewport_rows} 行 + 预加载 {policy.preload_rows} 行",
|
||||
f"视口外 {len(out_of_viewport)} 个 widget",
|
||||
]
|
||||
if use_pagination:
|
||||
reason_parts.append(
|
||||
f"超分页阈值 {policy.pagination_threshold},分 {len(pages)} 页加载"
|
||||
f"(每页 {policy.page_size})")
|
||||
if virtual_window:
|
||||
reason_parts.append(
|
||||
f"虚拟滚动窗口行 {virtual_window[0]:.1f}~{virtual_window[1]:.1f}")
|
||||
|
||||
return LazyLoadPlan(
|
||||
decisions=decisions,
|
||||
pages=pages,
|
||||
virtual_window=virtual_window,
|
||||
reason=";".join(reason_parts),
|
||||
)
|
||||
@@ -0,0 +1,397 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""性能预算与校验引擎(Issue #54 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||||
|
||||
PRD 5.5 验收口径:**首屏渲染 ≤ 2s**(弱网/中端设备)。本模块把这条口径落为
|
||||
**可校验的性能预算模型**——给定资源清单(HTML/CSS/JS/字体/图片/接口)与
|
||||
各资源的实测耗时,判定首屏是否达标、哪些资源超预算、给出可解释的优化建议。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
1. **性能预算即规格**:首屏预算(默认 2000ms)拆解到各资源类型(JS/CSS/
|
||||
字体/图片/接口/其它),每类给一条时间预算(``ResourceBudget``)。预算与
|
||||
行业模板绑定,换行业只改预算,不改前端代码。
|
||||
2. **资源测量**(``ResourceMeasurement``):一个资源 = 类型 + 名称 + 传输字节
|
||||
+ 解析/执行耗时 + 是否阻塞渲染。耗时来源 RUM(真实用户监控)/合成监控。
|
||||
3. **校验器**(``BudgetVerifier``):对一组测量值计算首屏总耗时(关键路径
|
||||
串行耗时),与预算比对,产出 :class:`BudgetReport`(达标/超预算状态 +
|
||||
超预算明细 + 优化建议,每条建议带 ``reason`` 可解释)。
|
||||
4. **关键路径**:阻塞渲染的资源(``block_render=True``)串行累加;非阻塞
|
||||
资源并行,仅取其最大值。该口径与浏览器首屏渲染时间(FCP/LCP)对齐。
|
||||
5. **纯标准库**:无 numpy/pyyaml 依赖,与 iAOP 零运行时依赖原则一致。
|
||||
|
||||
用法::
|
||||
|
||||
budget = PerformanceBudget.default_cockpit() # 首屏 ≤ 2s 默认预算
|
||||
measurements = [...] # 来自 RUM 的资源耗时
|
||||
report = BudgetVerifier(budget).verify(measurements)
|
||||
if not report.passed:
|
||||
for item in report.overrun:
|
||||
print(item.resource.name, item.advice)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
#: 默认首屏预算(毫秒)—— PRD 5.5:首屏 ≤ 2s。
|
||||
DEFAULT_FIRST_PAINT_BUDGET_MS = 2000
|
||||
|
||||
|
||||
class BudgetError(ValueError):
|
||||
"""性能预算声明/校验错误(预算负数、资源类型未知、测量值非法等)。"""
|
||||
|
||||
|
||||
class ResourceType(str, Enum):
|
||||
"""资源类型(决定其时间预算归属与首屏关键路径权重)。
|
||||
|
||||
对齐浏览器 Performance Resource Timing 的 ``initiatorType`` 子集,
|
||||
覆盖驾驶舱首屏主要资源。
|
||||
"""
|
||||
|
||||
HTML = "html" # 文档主体(必阻塞)
|
||||
CSS = "css" # 样式表(默认阻塞渲染)
|
||||
JS = "js" # 脚本(默认阻塞解析,可声明 async/defer)
|
||||
FONT = "font" # 字体(可阻塞文本渲染)
|
||||
IMAGE = "image" # 图片/图标
|
||||
API = "api" # 首屏 XHR/fetch 接口
|
||||
OTHER = "other" # 其它(追踪/埋点等)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return {
|
||||
ResourceType.HTML: "HTML 文档",
|
||||
ResourceType.CSS: "样式表",
|
||||
ResourceType.JS: "脚本",
|
||||
ResourceType.FONT: "字体",
|
||||
ResourceType.IMAGE: "图片",
|
||||
ResourceType.API: "接口",
|
||||
ResourceType.OTHER: "其它",
|
||||
}[self]
|
||||
|
||||
|
||||
# 类型注册表:字符串名 → ResourceType(模板配置用字符串,引擎内用枚举)。
|
||||
TYPE_REGISTRY: Dict[str, ResourceType] = {t.value: t for t in ResourceType}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceBudget:
|
||||
"""单类资源的时间预算(毫秒)。
|
||||
|
||||
Attributes:
|
||||
resource_type: 资源类型。
|
||||
time_ms: 该类资源在首屏关键路径上的时间预算(ms)。
|
||||
note: 预算说明(工艺/架构可解释,如 "首屏 JS 解析预算")。
|
||||
"""
|
||||
|
||||
resource_type: ResourceType
|
||||
time_ms: float
|
||||
note: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.time_ms < 0:
|
||||
raise BudgetError(
|
||||
f"ResourceBudget({self.resource_type.value}).time_ms 不能为负,"
|
||||
f"实际 {self.time_ms}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceBudget:
|
||||
"""性能预算:首屏总预算 + 分资源预算。
|
||||
|
||||
首屏总预算(``first_paint_ms``)= 关键路径渲染时间上限;分资源预算
|
||||
(``by_type``)是按类型的归口预算,用于定位超预算的资源类别。两者共同
|
||||
校验:任一类超预算或首屏总耗时超 ``first_paint_ms`` 即判 FAIL。
|
||||
|
||||
Attributes:
|
||||
name: 预算名(如 "cockpit-default")。
|
||||
first_paint_ms: 首屏渲染总预算(ms),默认 2000。
|
||||
by_type: 类型 → :class:`ResourceBudget`。
|
||||
description: 预算描述(引 PRD 口径)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
first_paint_ms: float = DEFAULT_FIRST_PAINT_BUDGET_MS
|
||||
by_type: Dict[ResourceType, ResourceBudget] = field(default_factory=dict)
|
||||
description: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.first_paint_ms <= 0:
|
||||
raise BudgetError(
|
||||
f"first_paint_ms 必须 > 0,实际 {self.first_paint_ms}")
|
||||
for rb in self.by_type.values():
|
||||
if not isinstance(rb, ResourceBudget):
|
||||
raise BudgetError(f"by_type 值必须是 ResourceBudget,实际 {type(rb)}")
|
||||
|
||||
# -- 构造便捷方法 ----------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def default_cockpit(cls) -> "PerformanceBudget":
|
||||
"""驾驶舱默认预算(首屏 ≤ 2s,PRD 5.5)。
|
||||
|
||||
分资源预算(合计 1900ms,留 100ms 渲染裕量):
|
||||
- HTML 文档 200ms(首字节 + 解析)
|
||||
- CSS 300ms(关键样式阻塞渲染)
|
||||
- JS 800ms(脚本解析执行,最大头)
|
||||
- 字体 200ms(首屏字体加载)
|
||||
- 接口 300ms(首屏关键接口)
|
||||
- 图片 100ms(占位/骨架)
|
||||
"""
|
||||
return cls(
|
||||
name="cockpit-default",
|
||||
first_paint_ms=DEFAULT_FIRST_PAINT_BUDGET_MS,
|
||||
description="驾驶舱默认性能预算(PRD 5.5:首屏 ≤ 2s)",
|
||||
by_type={
|
||||
ResourceType.HTML: ResourceBudget(
|
||||
ResourceType.HTML, 200, "首字节 + HTML 解析"),
|
||||
ResourceType.CSS: ResourceBudget(
|
||||
ResourceType.CSS, 300, "关键样式阻塞渲染"),
|
||||
ResourceType.JS: ResourceBudget(
|
||||
ResourceType.JS, 800, "脚本解析执行(首屏最大头)"),
|
||||
ResourceType.FONT: ResourceBudget(
|
||||
ResourceType.FONT, 200, "首屏字体加载"),
|
||||
ResourceType.API: ResourceBudget(
|
||||
ResourceType.API, 300, "首屏关键接口(趋势/KPI)"),
|
||||
ResourceType.IMAGE: ResourceBudget(
|
||||
ResourceType.IMAGE, 100, "占位/骨架图"),
|
||||
},
|
||||
)
|
||||
|
||||
# -- 查询 ------------------------------------------------------------
|
||||
|
||||
def budget_for(self, resource_type: ResourceType) -> float:
|
||||
"""取某类资源的时间预算(ms);未声明该类 → 返回 0(无归口预算)。"""
|
||||
rb = self.by_type.get(resource_type)
|
||||
return rb.time_ms if rb else 0.0
|
||||
|
||||
def covered_types(self) -> List[ResourceType]:
|
||||
"""已声明预算的资源类型(有序)。"""
|
||||
return [rt for rt in ResourceType if rt in self.by_type]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResourceMeasurement:
|
||||
"""单个资源的实测耗时(来自 RUM / 合成监控)。
|
||||
|
||||
Attributes:
|
||||
name: 资源名/URL(可读,用于报告定位)。
|
||||
resource_type: 资源类型。
|
||||
duration_ms: 单资源耗时(传输 + 解析 + 执行,ms)。
|
||||
size_bytes: 传输字节(可选,用于体积建议)。
|
||||
block_render: 是否阻塞渲染(关键路径串行累加;async/defer/非首屏=false)。
|
||||
"""
|
||||
|
||||
name: str
|
||||
resource_type: ResourceType
|
||||
duration_ms: float
|
||||
size_bytes: int = 0
|
||||
block_render: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise BudgetError("ResourceMeasurement.name 不能为空")
|
||||
if self.duration_ms < 0:
|
||||
raise BudgetError(
|
||||
f"资源 {self.name!r} duration_ms 不能为负,实际 {self.duration_ms}")
|
||||
if self.size_bytes < 0:
|
||||
raise BudgetError(
|
||||
f"资源 {self.name!r} size_bytes 不能为负,实际 {self.size_bytes}")
|
||||
|
||||
|
||||
class BudgetStatus(str, Enum):
|
||||
"""预算校验状态。"""
|
||||
|
||||
PASS = "pass" # 达标
|
||||
FAIL = "fail" # 超预算
|
||||
EMPTY = "empty" # 无测量值(无可校验内容)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetOverrun:
|
||||
"""单条超预算记录(可解释:附 reason + advice)。"""
|
||||
|
||||
resource_name: str
|
||||
resource_type: ResourceType
|
||||
actual_ms: float
|
||||
budget_ms: float
|
||||
reason: str # 为何超预算(事实陈述,如 "JS 耗时 950ms 超预算 800ms")
|
||||
advice: str # 优化建议(如 "代码分割 / 懒加载非首屏图表")
|
||||
|
||||
@property
|
||||
def overrun_ms(self) -> float:
|
||||
return max(0.0, self.actual_ms - self.budget_ms)
|
||||
|
||||
@property
|
||||
def overrun_ratio(self) -> float:
|
||||
"""超幅比例(>1.0 表示超预算;实际/预算)。"""
|
||||
return (self.actual_ms / self.budget_ms) if self.budget_ms > 0 else float("inf")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetReport:
|
||||
"""性能预算校验报告(可解释:状态 + 超预算明细 + 建议 + 总耗时)。"""
|
||||
|
||||
status: BudgetStatus
|
||||
first_paint_ms: float # 测得的首屏关键路径总耗时
|
||||
budget_ms: float # 首屏预算
|
||||
by_type_actual: Dict[ResourceType, float] = field(default_factory=dict)
|
||||
overrun: List[BudgetOverrun] = field(default_factory=list)
|
||||
suggestions: List[str] = field(default_factory=list)
|
||||
reason: str = "" # 整体达标/不达标的可解释结论
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
"""是否达标(status == PASS)。"""
|
||||
return self.status == BudgetStatus.PASS
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"passed": self.passed,
|
||||
"first_paint_ms": round(self.first_paint_ms, 2),
|
||||
"budget_ms": self.budget_ms,
|
||||
"overrun_count": len(self.overrun),
|
||||
"by_type_actual": {rt.value: round(v, 2)
|
||||
for rt, v in self.by_type_actual.items()},
|
||||
"suggestions": list(self.suggestions),
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
class BudgetVerifier:
|
||||
"""性能预算校验器:测量值 → 报告。
|
||||
|
||||
关键路径耗时口径:
|
||||
- 阻塞渲染资源(``block_render=True``)的耗时**串行累加**;
|
||||
- 非阻塞资源耗时并行,仅取其最大值,与串行耗时相加为首屏总耗时。
|
||||
- 该口径对齐浏览器渲染:阻塞资源决定首次渲染时刻,并行资源不延后。
|
||||
|
||||
Args:
|
||||
budget: 性能预算。
|
||||
"""
|
||||
|
||||
def __init__(self, budget: PerformanceBudget) -> None:
|
||||
self.budget = budget
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def verify(self, measurements: Sequence[ResourceMeasurement]) -> BudgetReport:
|
||||
"""对一组资源测量值校验预算。"""
|
||||
if not measurements:
|
||||
return BudgetReport(
|
||||
status=BudgetStatus.EMPTY,
|
||||
first_paint_ms=0.0,
|
||||
budget_ms=self.budget.first_paint_ms,
|
||||
reason="无测量值,无法校验性能预算",
|
||||
)
|
||||
|
||||
# 1) 按类型聚合实际耗时(关键路径:阻塞串行 + 非阻塞取最大)
|
||||
by_type_actual: Dict[ResourceType, float] = {}
|
||||
blocking_total = 0.0
|
||||
non_blocking_max: Dict[ResourceType, float] = {}
|
||||
for m in measurements:
|
||||
by_type_actual.setdefault(m.resource_type, 0.0)
|
||||
if m.block_render:
|
||||
blocking_total += m.duration_ms
|
||||
# 该类阻塞耗时叠加到 by_type_actual(阻塞总和)
|
||||
by_type_actual[m.resource_type] += m.duration_ms
|
||||
else:
|
||||
# 非阻塞:该类型取所有非阻塞资源里的最大值
|
||||
prev = non_blocking_max.get(m.resource_type, 0.0)
|
||||
non_blocking_max[m.resource_type] = max(prev, m.duration_ms)
|
||||
|
||||
# 非阻塞类型累加其最大值(与阻塞串行相加为首屏总耗时)
|
||||
# 注意:某类型若同时有阻塞/非阻塞资源,非阻塞最大值与阻塞总和相加
|
||||
non_blocking_total = sum(non_blocking_max.values())
|
||||
first_paint_ms = blocking_total + non_blocking_total
|
||||
|
||||
# by_type_actual:阻塞已叠加;非阻塞类型补齐其最大值
|
||||
for rt, mx in non_blocking_max.items():
|
||||
if rt not in by_type_actual or by_type_actual[rt] == 0.0:
|
||||
by_type_actual[rt] = mx
|
||||
else:
|
||||
by_type_actual[rt] += mx
|
||||
|
||||
# 2) 逐类型比对预算,产出超预算明细 + 建议
|
||||
overrun: List[BudgetOverrun] = []
|
||||
suggestions: List[str] = []
|
||||
for rt in ResourceType:
|
||||
actual = by_type_actual.get(rt, 0.0)
|
||||
budget = self.budget.budget_for(rt)
|
||||
if budget <= 0:
|
||||
continue # 未声明该类预算(如 OTHER)跳过
|
||||
if actual > budget:
|
||||
advice = self._advice_for(rt, actual, budget)
|
||||
overrun.append(BudgetOverrun(
|
||||
resource_name=f"<{rt.value}>",
|
||||
resource_type=rt,
|
||||
actual_ms=actual,
|
||||
budget_ms=budget,
|
||||
reason=f"{rt.label} 耗时 {actual:.0f}ms 超预算 "
|
||||
f"{budget:.0f}ms(超 {actual - budget:.0f}ms)",
|
||||
advice=advice,
|
||||
))
|
||||
suggestions.append(advice)
|
||||
|
||||
# 3) 首屏总预算校验
|
||||
over_total = first_paint_ms > self.budget.first_paint_ms
|
||||
if not overrun and not over_total:
|
||||
margin = self.budget.first_paint_ms - first_paint_ms
|
||||
return BudgetReport(
|
||||
status=BudgetStatus.PASS,
|
||||
first_paint_ms=first_paint_ms,
|
||||
budget_ms=self.budget.first_paint_ms,
|
||||
by_type_actual=dict(by_type_actual),
|
||||
overrun=[],
|
||||
suggestions=[],
|
||||
reason=f"首屏 {first_paint_ms:.0f}ms ≤ 预算 "
|
||||
f"{self.budget.first_paint_ms:.0f}ms(裕量 {margin:.0f}ms)",
|
||||
)
|
||||
|
||||
# 不达标:总耗时或某类超预算
|
||||
reasons: List[str] = []
|
||||
if over_total:
|
||||
reasons.append(
|
||||
f"首屏 {first_paint_ms:.0f}ms 超总预算 "
|
||||
f"{self.budget.first_paint_ms:.0f}ms")
|
||||
if overrun:
|
||||
reasons.append(f"{len(overrun)} 类资源超归口预算")
|
||||
if not suggestions:
|
||||
suggestions.append(
|
||||
f"首屏总耗时 {first_paint_ms:.0f}ms 超预算,需整体压缩关键路径"
|
||||
f"(代码分割 / 懒加载 / 预渲染)")
|
||||
return BudgetReport(
|
||||
status=BudgetStatus.FAIL,
|
||||
first_paint_ms=first_paint_ms,
|
||||
budget_ms=self.budget.first_paint_ms,
|
||||
by_type_actual=dict(by_type_actual),
|
||||
overrun=overrun,
|
||||
suggestions=suggestions,
|
||||
reason=";".join(reasons),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _advice_for(rt: ResourceType, actual: float, budget: float) -> str:
|
||||
"""针对资源类型给出可解释优化建议(reason 体现为什么)。"""
|
||||
if rt is ResourceType.JS:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:代码分割"
|
||||
f"(按路由拆 chunk)、首屏非关键脚本 defer/async、"
|
||||
f"Tree-shaking 移除死代码")
|
||||
if rt is ResourceType.CSS:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:抽取关键"
|
||||
f"内联 CSS(Critical CSS)、首屏外样式异步加载")
|
||||
if rt is ResourceType.FONT:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:font-display:"
|
||||
f"swap、preload 首屏字体、子集化中文字体")
|
||||
if rt is ResourceType.API:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:首屏接口"
|
||||
f"合并(BFF)、SSR 预取、CDN 边缘缓存")
|
||||
if rt is ResourceType.IMAGE:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:懒加载"
|
||||
f"viewport 外图片、WebP/AVIF、响应式 srcset")
|
||||
if rt is ResourceType.HTML:
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:启用 HTTP/2"
|
||||
f"推送、SSR 预渲染、CDN 边缘缓存文档")
|
||||
return (f"{rt.label} 超预算 {actual - budget:.0f}ms:优化或移出"
|
||||
f"首屏关键路径")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user