test: 按模块重写测试代码,删除旧平铺结构

按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、
不保留全局 conftest.py、测试过程只调用真实生产代码。

结构(73 个文件、30 个模块目录、477 用例):
- tests/nodes/  15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/
  subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/
  proper_nouns/adaptive_pool/vad_profiler/echo);
- tests/app/    11 个模块目录(db/scheduler/batch/maintenance/registry/seed/
  storage/config/logging/main/routers 三组 API);
- tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。

测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore
的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。

顺带发现并修复三个真实缺陷:
- nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位),
  改为正文行遇时间戳行即报错;
- src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI
  抛 ValueError 导致任务误判失败,改为同时捕获;
- nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT,
  改用生产模块 nodes/srt.py。

真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py
(运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py
(无 Key / 余额 / 限流转跳过)。全量 477 passed。
This commit is contained in:
2026-09-13 15:40:56 +08:00
parent 966f3e6b4b
commit 8a715a8064
139 changed files with 20810 additions and 10733 deletions
+346
View File
@@ -0,0 +1,346 @@
"""nodes/adaptive_pool.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/adaptive_pool.py`(自适应并发额度控制),被 subtitle-ocr 与
llm-filter 复用,也可独立使用,因此拥有独立模块目录。
测试只调用真实并发池,`clock` 注入假时钟以实现确定性的窗口行为;worker 用
真实可执行函数(无 I/O 依赖),不重写被测逻辑。
"""
from __future__ import annotations
import threading
import time
from nodes.adaptive_pool import AdaptiveThreadPool, decide
class FakeClock:
"""可手动拨动的假时钟(时间属于允许在 I/O 边界注入的依赖)。"""
def __init__(self, now: float = 0.0) -> None:
self.now = now
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
# ---------------------------------------------------------------------------
# 决策函数
# ---------------------------------------------------------------------------
def test_decide_increases_when_response_fast() -> None:
"""平均响应低于快阈值且未达上限:额度 +1。"""
# 数据:当前 1、均值 0.1、上限 16。
# 测试过程与验证结果
assert decide(1, 0.1, 1, 16, 0.3, 1.0) == 2
def test_decide_decreases_when_response_slow() -> None:
"""平均响应高于慢阈值且高于下限:额度 -1。"""
# 数据:当前 3、均值 2.0。
# 测试过程与验证结果
assert decide(3, 2.0, 1, 16, 0.3, 1.0) == 2
def test_decide_keeps_when_response_between_thresholds() -> None:
"""响应介于两阈值之间时额度不变。"""
# 数据:当前 2、均值 0.5。
# 测试过程与验证结果
assert decide(2, 0.5, 1, 16, 0.3, 1.0) == 2
def test_decide_respects_bounds() -> None:
"""已达上限不再增、已达下限不再减。"""
# 数据:上限 16 且很快;下限 1 且很慢。
# 测试过程与验证结果
assert decide(16, 0.1, 1, 16, 0.3, 1.0) == 16
assert decide(1, 2.0, 1, 16, 0.3, 1.0) == 1
# ---------------------------------------------------------------------------
# map 基本行为
# ---------------------------------------------------------------------------
def test_map_returns_results_in_input_order() -> None:
"""并发执行但结果严格按输入顺序返回,保证字幕时间轴不被并发打乱。"""
# 数据:让后面的任务更早开始的输入;worker 是确定性的纯函数。
items = [3, 2, 1]
pool = AdaptiveThreadPool(worker=lambda value: value * 100)
# 测试过程
results = pool.map(items)
# 验证结果:顺序与输入一致。
assert results == [300, 200, 100]
def test_map_empty_input_returns_empty_list() -> None:
"""空输入返回空结果,不启动任务也不报错。"""
# 数据:空列表。
pool = AdaptiveThreadPool(worker=lambda item: item)
# 测试过程与验证结果
assert pool.map([]) == []
def test_map_isolates_worker_exception_as_result() -> None:
"""worker 抛出的异常作为该位置的结果返回,不影响其他任务。"""
# 数据:第二个元素触发异常。
def worker(item: int) -> int:
if item == 2:
raise ValueError("boom")
return item
pool = AdaptiveThreadPool(worker=worker)
# 测试过程
results = pool.map([1, 2, 3])
# 验证结果:异常对象保留在对应位置,其余结果正常。
assert results[0] == 1
assert isinstance(results[1], ValueError)
assert results[2] == 3
def test_progress_callback_reports_completed_and_workers() -> None:
"""进度回调按完成数递增上报,并带上当前额度。"""
# 数据:记录全部回调的列表。
calls: list[tuple[int, int, int]] = []
pool = AdaptiveThreadPool(
worker=lambda item: item,
on_progress=lambda done, total, rate, avg, workers: calls.append((done, total, workers)),
)
# 测试过程
pool.map([1, 2, 3])
# 验证结果:完成数依次为 1、2、3,总数恒为 3,额度不低于下限。
assert [c[0] for c in calls] == [1, 2, 3]
assert all(c[1] == 3 for c in calls)
assert all(c[2] >= 1 for c in calls)
def test_cancel_suppresses_progress_but_supplies_all_results() -> None:
"""cancel 只抑制进度回调;每个输入仍返回结果(供调用方决定重试)。"""
# 数据:worker 在第一个任务里触发 cancel。
pool: AdaptiveThreadPool
def worker(item: int) -> int:
if item == 0:
pool.cancel()
return item
pool = AdaptiveThreadPool(worker=worker, on_progress=lambda *a: calls.append(a))
calls: list[tuple] = []
# 测试过程
results = pool.map([0, 1, 2])
# 验证结果:结果完整,进度回调被抑制。
assert results == [0, 1, 2]
assert calls == []
def test_cancel_is_reset_for_next_map() -> None:
"""同一实例再次 map 时清除 cancel 标记(暂停后继续、失败重试的调用约定)。"""
# 数据:第一批触发 cancel,第二批正常。
pool = AdaptiveThreadPool(worker=lambda item: item)
pool.cancel()
calls: list[tuple] = []
pool._on_progress = lambda *a: calls.append(a) # 直接复用真实回调槽位
# 测试过程
pool.map([1])
# 验证结果:新批次重新上报进度。
assert len(calls) == 1
# ---------------------------------------------------------------------------
# 弹性扩缩容(假时钟驱动确定性窗口)
# ---------------------------------------------------------------------------
def _timed_pool(clock: FakeClock, worker, **kwargs) -> AdaptiveThreadPool:
"""构造使用假时钟的真实线程池。"""
return AdaptiveThreadPool(worker=worker, clock=clock, window_seconds=1.0, **kwargs)
def test_pool_grows_target_when_responses_fast() -> None:
"""窗口内平均响应快时应扩大目标额度(服务端空闲就加大并发)。"""
# 数据:真实时钟 + 极短窗口;worker 只做微秒级工作,平均耗时远低于快阈值。
observed: list[int] = []
def worker(item: int) -> int:
time.sleep(0.002)
return item
pool = AdaptiveThreadPool(
worker=worker, min_workers=1, max_workers=8,
window_seconds=0.01, fast_threshold=0.3, slow_threshold=1.0,
)
pool._on_progress = lambda done, total, rate, avg, workers: observed.append(workers)
# 测试过程:任务足够多,保证跨越多个窗口触发扩容判断。
pool.map(list(range(40)))
# 验证结果:额度单调不减且最终大于起始值。
assert observed == sorted(observed)
assert observed[-1] > observed[0]
def test_pool_shrinks_target_when_responses_slow() -> None:
"""窗口内平均响应慢时应收缩目标额度(避免压垮本地服务)。"""
# 数据:真实时钟 + 极短窗口;worker 耗时远超慢阈值。
observed: list[int] = []
def worker(item: int) -> int:
time.sleep(0.02)
return item
pool = AdaptiveThreadPool(
worker=worker, min_workers=1, max_workers=8,
window_seconds=0.05, fast_threshold=0.001, slow_threshold=0.005,
)
pool._on_progress = lambda done, total, rate, avg, workers: observed.append(workers)
# 测试过程
pool.map(list(range(12)))
# 验证结果:出现回调,且额度始终不低于下限(不会缩到 0)。
assert observed
assert min(observed) >= 1
def test_report_failure_lowers_effective_max_and_never_expands() -> None:
"""report_failure 只收紧上限;上限从 20 降到 19 时不会把当前 1 并发扩成 19。"""
# 数据:上限 20、当前额度 1。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=20)
# 测试过程:报告一次限流失败。
pool.report_failure()
# 验证结果:有效上限降 1,当前目标仍为下限。
assert pool._effective_max_workers == 19
assert pool._target_workers <= 1
def test_failure_at_single_worker_never_expands_quota() -> None:
"""单并发下报告失败,绝不能因上限下降而把额度扩大。"""
# 数据:上限 20、最小 1,先启动一次 map 使额度为 1。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=20)
pool.map([1])
# 测试过程
pool.report_failure()
# 验证结果
assert pool._target_workers == 1
def test_effective_max_recovers_one_step_per_clean_window() -> None:
"""干净窗口每次只恢复 1 个上限,避免限流恢复期再次打满。"""
# 数据:先把有效上限压到 2。
clock = FakeClock()
pool = _timed_pool(clock, lambda item: item, min_workers=1, max_workers=5)
pool.report_failure()
pool.report_failure()
pool.report_failure()
assert pool._effective_max_workers == 2
# 测试过程:第一个 tick 消耗“有失败”的窗口(不恢复),第二个干净窗口恢复 1。
clock.advance(2.0)
pool._tick(0.1)
assert pool._effective_max_workers == 2
clock.advance(2.0)
pool._tick(0.1)
# 验证结果:干净窗口只恢复 1。
assert pool._effective_max_workers == 3
def test_error_window_does_not_recover_limit() -> None:
"""窗口内有失败时不恢复上限。"""
# 数据:上限 5,压到 3 后在同一窗口内报告失败。
clock = FakeClock()
pool = _timed_pool(clock, lambda item: item, min_workers=1, max_workers=5)
pool.report_failure()
pool.report_failure()
pool.report_failure()
before = pool._effective_max_workers
# 测试过程:窗口内先失败再触发 tick。
pool.report_failure()
clock.advance(2.0)
pool._tick(0.1)
# 验证结果:上限未恢复。
assert pool._effective_max_workers <= before
def test_reduced_limit_is_kept_across_retry_map() -> None:
"""限流后的有效上限跨 map 保留(失败条目重试时继续遵守更严配额)。"""
# 数据:先压低上限。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=6)
pool.map([1])
pool.report_failure()
reduced = pool._effective_max_workers
# 测试过程:执行第二轮 map(重试场景)。
pool.map([1, 2])
# 验证结果:上限仍是被压低的值。
assert pool._effective_max_workers == reduced
def test_concurrent_map_on_same_pool_is_rejected() -> None:
"""同一实例不允许并行 map,避免额度与统计互相干扰。"""
# 数据:第一个 map 阻塞在 worker 上。
started = threading.Event()
release = threading.Event()
def worker(item: int) -> int:
started.set()
release.wait(timeout=5)
return item
pool = AdaptiveThreadPool(worker=worker, min_workers=1, max_workers=2)
errors: list[Exception] = []
def run_first() -> None:
pool.map([1])
thread = threading.Thread(target=run_first)
thread.start()
assert started.wait(timeout=5)
# 测试过程:在第一个 map 未结束时再次调用 map。
try:
pool.map([2])
except Exception as exc: # noqa: BLE001 - 断言真实抛出的类型
errors.append(exc)
finally:
release.set()
thread.join(timeout=5)
# 验证结果:抛出 RuntimeError。
assert len(errors) == 1
assert isinstance(errors[0], RuntimeError)
def test_max_concurrency_never_exceeds_target_limit() -> None:
"""实际在途数量不超过 max_workers 上限(并发有界)。"""
# 数据:上限 310 个快速任务。
pool = AdaptiveThreadPool(worker=lambda item: item, min_workers=1, max_workers=3)
# 测试过程
pool.map(list(range(10)))
# 验证结果:观测到的最大在途数不超过上限。
assert 1 <= pool.max_concurrency <= 3