Initialize Trellis project guidelines
This commit is contained in:
180
.trellis/spec/blender/testing.md
Normal file
180
.trellis/spec/blender/testing.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# 测试
|
||||
|
||||
> 适用:改动 `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` 就立刻获得测试覆盖的资格。
|
||||
|
||||
---
|
||||
|
||||
## 反模式
|
||||
|
||||
| 反模式 | 后果 |
|
||||
|---|---|
|
||||
| 把当前输出粘成期望值 | 实现有 bug 时测试照样绿 |
|
||||
| 新几何函数不配退化输入测试 | 真实数据一进来就崩 |
|
||||
| 退化输入抛异常而不是返回空 | 一个坏多边形中断整片区域 |
|
||||
| 带随机的函数不收 `seed` | parity 校验永久红 |
|
||||
| mock 掉 OSM 解析 | 测不到它唯一的价值 |
|
||||
| 在测试里 import `bpy` 侧模块 | 整个套件无法运行 |
|
||||
| 只测一种绕向 | 反向多边形进来才发现 |
|
||||
Reference in New Issue
Block a user