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
+53
View File
@@ -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 强制加载、视口边界、分页/虚拟滚动、输入校验。
+46
View File
@@ -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())
+328
View File
@@ -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),
)
+397
View File
@@ -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()