Files
osmWorkflow/.trellis/spec/blender/testing.md

191 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 测试
> 适用:改动 `osmassets/osm.py`、`osmassets/geom.py`,或往里加新的纯函数。
---
## 怎么跑
```bash
python3 -m unittest discover blender/tests
```
**不需要 Blender**,用系统 Python 就行。当前 51 个用例,运行约 0.01 秒。
这是全仓唯一的自动化测试。快到没有理由不在每次改动后跑一遍。
能这么跑的前提是 `osmassets` 的[依赖分层](./module-structure.md)——
`test_pure.py:19` 手动把 `blender/` 塞进 `sys.path`,然后只 import 纯 Python 模块。
---
## 最重要的一条:期望值必须从几何推导
`test_pure.py:9-11` 写得很直白:
> The expected values are derived from the geometry, not captured from the
> implementation — **a test that just records current output would ratify a bug.**
具体做法是在测试里写清楚**为什么**是这个数:
```python
def test_half_outside_polygon_is_cut_at_the_boundary(self):
clipped = clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0)
self.assertTrue(all(x <= 5.0 + 1e-9 for x, _ in clipped))
# A 10x10 square clipped to half its width is a 5x10 rectangle.
self.assertAlmostEqual(polygon_area(clipped), 50.0, places=6)
```
反例——**不要这样写**
```python
def test_clip(self):
# 跑一遍把输出粘过来
self.assertEqual(clip_polygon(SQUARE, 0.0, 5.0, 0.0, 10.0),
[(0.0, 0.0), (5.0, 0.0), (5.0, 10.0), (0.0, 10.0)])
```
这种测试在实现正确时和实现错误时**同样会通过**。它固化的是当前行为,不是需求。
**自检**:把被测的那个特性从实现里删掉,测试还能过吗?能过就是无效测试。
---
## 每个几何函数都要覆盖退化输入
这是本套测试最系统的部分。`geom.py` 的函数会收到真实 OSM 数据里的各种畸形几何,
所以每个函数都有一个 `test_degenerate_input`
| 退化情形 | 例子 |
|---|---|
| 空输入 | `clip_polygon([], ...)``[]` (`:73`) |
| 点数不足成面 | `clip_polygon([(0,0),(1,1)], ...)``[]` (`:74`) |
| 完全在裁剪框外 | `clip_polygon(SQUARE, 20,30,20,30)``[]` (`:70`) |
| 零长线段 | `sample_tree_row` 跳过而非除零 (`:203`) |
| 重复顶点 | `distance_to_ring` 不除零 (`:178`) |
| 参数为零 | `sample_ring_boundary(SQUARE, spacing=0.0)``[]` (`:123`) |
| 单点输入 | `sample_polygon_interior([(0,0)], ...)``[]` (`:146`) |
**加新几何函数就配一个 `test_degenerate_input`。** 约定是"返回空/零"而不是抛异常——
一个坏多边形不该中断整片区域的构建。
### 除零守卫要指名道姓
覆盖某个具体守卫时,注释写清楚针对哪一行:
```python
def test_axis_aligned_edge_does_not_divide_by_zero(self):
# A vertical edge crossing the x clip plane exercises the b[0] == a[0]
# guard in the intersection lambdas.
```
这样守卫被误删时,失败的测试能直接说明它保护的是什么(`test_pure.py:76-78`)。
---
## 其余几条约定
### 随机采样必须验 seed 可复现
```python
def test_seed_is_deterministic(self):
first = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
second = sample_polygon_interior(SQUARE, spacing=3.0, seed=7)
self.assertEqual(first, second)
```
不可复现的采样会让 [parity 校验](../guides/artifact-parity-guide.md)永久性地红。
任何带随机的新函数都要收 `seed` 参数并配这个测试(`test_pure.py:135-138`)。
### 绕向无关的性质要两个方向都测
`sample_ring_boundary``inset` 对顺时针和逆时针都必须往内缩:
```python
ccw = sample_ring_boundary(SQUARE, spacing=10.0, inset=1.0)
cw = sample_ring_boundary(list(reversed(SQUARE)), spacing=10.0, inset=1.0)
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in ccw))
self.assertTrue(all(point_in_polygon((x, y), SQUARE) for x, y, _, _ in cw))
```
同理 `polygon_area` 对绕向不敏感、`signed_polygon_area` 敏感,两者分别验
`test_pure.py:88-91, 109-115`)。
### 跨边界的连续性要单独测
真实几何常见的坑是"分段处理时每段各自重新开始"
```python
def test_spacing_carries_across_segment_joins(self):
# Two 3m segments with 4m spacing: the second sample must land 1m into
# the second segment, not restart at its origin.
```
`test_pure.py:190-196`
### 解析器的容错语义要写死
`parse_osm` 的两档行为必须都有测试(`test_pure.py:343-356`
- **坏节点跳过,不致命**`lon='oops'` 的节点被忽略,其余照常解析
- **缺 `bounds` 直接抛 `RuntimeError`**:没有 bounds 就无法建立投影,继续下去毫无意义
分档理由见 `generate_scene.py:9-12`OSM 导出可能带上区域外的 relation 成员,
所以范围只认 `bounds` 元素,不用全部节点算包围盒。
### 用真实格式的 fixture
`OSM_SAMPLE``test_pure.py:285`)是一段真的 OSM XML故意塞进了坏节点、
`action='delete'` 的 way、引用了不存在节点的 way。写进临时文件再解析
`tearDown``os.unlink`
**别 mock 解析器。** 解析器的价值就在于处理真实世界的脏数据。
---
## 命名
- 类名 = 被测函数的驼峰 + `Test``ClipPolygonTest``SampleTreeRowTest`
- 方法名是**陈述句,说明这个行为是什么**,不是 `test_case_1`
`test_polygon_keeps_only_the_exterior_ring`
`test_trailing_point_is_skipped_when_it_would_double_plant`
方法名读起来就是这个函数的规格说明。
---
## 测不到的部分怎么办
`bpy` 层(`mesh.py``materials.py``tree.py`、两个入口脚本)**没有单元测试**
也不打算有——它们需要真实的 Blender 运行时。
这一层的回归防线是 **parity 校验**:结构摘要比对,而不是单元测试。
见[产物一致性指南](../guides/artifact-parity-guide.md)。
**推论**:能挪进纯 Python 层的逻辑就挪。一个函数只要不碰 `bpy`
放进 `geom.py` 就立刻获得测试覆盖的资格。
### 以 MeshBatch 为边界的静态设施测试
少数 bpy 要素模块的价值在于确定性地向 `MeshBatch` 追加顶点与面,而不是调用 bpy API
本身。对这类模块(例如 `osmassets/traffic_signals.py`),应在 `blender/tests/` 用假的
`osmassets.mesh.MeshBatch` 导入模块,断言有效输入的装配数量和关键几何方向。这样可覆盖
“校验函数意外返回空、所有要素被静默跳过”这一类错误,不必依赖可用的 Blender 进程。
测试必须在本文件列出的 `python3 -m unittest discover blender/tests` 命令下独立运行;测试
文件自己添加 `blender/``sys.path`,不能依赖其他测试的导入顺序。
---
## 反模式
| 反模式 | 后果 |
|---|---|
| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 |
| 新几何函数不配退化输入测试 | 真实数据一进来就崩 |
| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 |
| 带随机的函数不收 `seed` | parity 校验永久红 |
| mock 掉 OSM 解析 | 测不到它唯一的价值 |
| 在测试里 import `bpy` 侧模块 | 整个套件无法运行 |
| 只测一种绕向 | 反向多边形进来才发现 |