diff --git a/templates/ti-cl4/perf-budget/README.md b/templates/ti-cl4/perf-budget/README.md new file mode 100644 index 0000000..71b3acc --- /dev/null +++ b/templates/ti-cl4/perf-budget/README.md @@ -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 强制加载、视口边界、分页/虚拟滚动、输入校验。 diff --git a/templates/ti-cl4/perf-budget/__init__.py b/templates/ti-cl4/perf-budget/__init__.py new file mode 100644 index 0000000..fa3a305 --- /dev/null +++ b/templates/ti-cl4/perf-budget/__init__.py @@ -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", +] diff --git a/templates/ti-cl4/perf-budget/_sanity_check.py b/templates/ti-cl4/perf-budget/_sanity_check.py new file mode 100644 index 0000000..6f5578a --- /dev/null +++ b/templates/ti-cl4/perf-budget/_sanity_check.py @@ -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()) diff --git a/templates/ti-cl4/perf-budget/lazy_load.py b/templates/ti-cl4/perf-budget/lazy_load.py new file mode 100644 index 0000000..8ae6034 --- /dev/null +++ b/templates/ti-cl4/perf-budget/lazy_load.py @@ -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), + ) diff --git a/templates/ti-cl4/perf-budget/perf_budget.py b/templates/ti-cl4/perf-budget/perf_budget.py new file mode 100644 index 0000000..9973a11 --- /dev/null +++ b/templates/ti-cl4/perf-budget/perf_budget.py @@ -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"首屏关键路径") diff --git a/templates/ti-cl4/perf-budget/tests/_bootstrap.py b/templates/ti-cl4/perf-budget/tests/_bootstrap.py new file mode 100644 index 0000000..74b8758 --- /dev/null +++ b/templates/ti-cl4/perf-budget/tests/_bootstrap.py @@ -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) diff --git a/templates/ti-cl4/perf-budget/tests/test_lazy_load.py b/templates/ti-cl4/perf-budget/tests/test_lazy_load.py new file mode 100644 index 0000000..f6cd213 --- /dev/null +++ b/templates/ti-cl4/perf-budget/tests/test_lazy_load.py @@ -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() diff --git a/templates/ti-cl4/perf-budget/tests/test_perf_budget.py b/templates/ti-cl4/perf-budget/tests/test_perf_budget.py new file mode 100644 index 0000000..80f1285 --- /dev/null +++ b/templates/ti-cl4/perf-budget/tests/test_perf_budget.py @@ -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()