Skip to content

Commit 93041ae

Browse files
Merge pull request #6 from OlafenwaMoses/moses/tests-improv
Tests improvements, cleanups and CI action
2 parents 35d4e95 + 8c1b14c commit 93041ae

20 files changed

Lines changed: 1184 additions & 146 deletions

.github/workflows/ci.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
# Cancel in-progress runs for the same branch on new pushes.
9+
# Never cancel runs on main (every merge must be fully tested).
10+
concurrency:
11+
group: ${{ github.workflow }}-${{ github.ref }}
12+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
13+
14+
jobs:
15+
# ────────────────────────────────────────────────────────────────────────────
16+
# Lint — ruff (fastest possible feedback, runs in parallel with unit tests)
17+
# ────────────────────────────────────────────────────────────────────────────
18+
lint:
19+
name: Lint
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- uses: astral-sh/setup-uv@v5
25+
with:
26+
python-version: "3.12"
27+
28+
- name: Install dev dependencies
29+
run: uv sync --frozen --group dev
30+
31+
- name: Ruff check
32+
run: uv run ruff check .
33+
34+
# ────────────────────────────────────────────────────────────────────────────
35+
# Unit tests — fast, no model download, runs in parallel with lint
36+
# ────────────────────────────────────────────────────────────────────────────
37+
unit:
38+
name: Unit tests
39+
runs-on: ubuntu-latest
40+
needs: [lint]
41+
steps:
42+
- uses: actions/checkout@v4
43+
44+
- uses: astral-sh/setup-uv@v5
45+
with:
46+
python-version: "3.12"
47+
48+
- name: Install dependencies
49+
run: uv sync --frozen
50+
51+
- name: Run unit tests
52+
run: uv run pytest tests/unit/ -v
53+
54+
# ────────────────────────────────────────────────────────────────────────────
55+
# Integration tests — real inference, requires model weights
56+
# Only runs after lint + unit both pass (fail fast)
57+
# ────────────────────────────────────────────────────────────────────────────
58+
integration:
59+
name: Integration tests
60+
runs-on: ubuntu-latest
61+
needs: [unit]
62+
timeout-minutes: 30
63+
64+
steps:
65+
- uses: actions/checkout@v4
66+
67+
- uses: astral-sh/setup-uv@v5
68+
with:
69+
python-version: "3.12"
70+
71+
- name: Install dependencies
72+
run: uv sync --frozen
73+
74+
- name: Set model cache path
75+
run: echo "VIZION3D_MODEL_CACHE=$HOME/.cache/vizion3d/models" >> "$GITHUB_ENV"
76+
77+
- name: Cache model weights
78+
uses: actions/cache@v4
79+
with:
80+
path: ${{ env.VIZION3D_MODEL_CACHE }}
81+
# Key is tied to the model filename — bump if the model changes
82+
key: depth-anything-v2-vitb-pth
83+
84+
- name: Run integration tests
85+
env:
86+
VIZION3D_TEST_COLD_LIMIT: ${{ vars.VIZION3D_TEST_COLD_LIMIT }}
87+
VIZION3D_TEST_WARM_LIMIT: ${{ vars.VIZION3D_TEST_WARM_LIMIT }}
88+
run: uv run pytest tests/integration/ -v

tests/assets/indoor_scene.jpg

42.7 KB
Loading

tests/integration/__init__.py

Whitespace-only changes.

tests/integration/conftest.py

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
"""
2+
Integration-test configuration.
3+
4+
Provides
5+
--------
6+
indoor_image_bytes — real 640×480 indoor-scene JPEG for inference
7+
local_model_path — explicit .pth path in a session-scoped tmp dir
8+
(symlinked from the default cache if available,
9+
otherwise freshly downloaded; cleaned up by pytest)
10+
grpc_client_stub — live LiftingService stub backed by an in-process
11+
gRPC server running in a background thread-pool
12+
timing_collector — session-wide store that every test appends to
13+
pytest_terminal_summary — pretty inference-timing report printed at the end
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from concurrent import futures
19+
from dataclasses import dataclass
20+
from itertools import groupby
21+
from pathlib import Path
22+
from typing import List
23+
24+
import grpc
25+
import pytest
26+
27+
# ──────────────────────────────────────────────────────────────────────────────
28+
# Constants
29+
# ──────────────────────────────────────────────────────────────────────────────
30+
31+
N_RUNS = 5
32+
ASSETS_DIR = Path(__file__).parent.parent / "assets"
33+
34+
35+
# ──────────────────────────────────────────────────────────────────────────────
36+
# Timing collector
37+
# ──────────────────────────────────────────────────────────────────────────────
38+
39+
@dataclass
40+
class TimingRecord:
41+
entry_point: str
42+
scenario: str
43+
run: int
44+
duration: float
45+
output_dir: str = ""
46+
47+
48+
class InferenceTimingCollector:
49+
def __init__(self):
50+
self.records: List[TimingRecord] = []
51+
52+
def add(
53+
self,
54+
entry_point: str,
55+
scenario: str,
56+
run: int,
57+
duration: float,
58+
output_dir: str = "",
59+
) -> None:
60+
self.records.append(
61+
TimingRecord(entry_point, scenario, run, duration, output_dir)
62+
)
63+
64+
65+
# Module-level singleton — pytest_terminal_summary reads from it after the session
66+
_COLLECTOR = InferenceTimingCollector()
67+
68+
69+
@pytest.fixture(scope="session")
70+
def timing_collector() -> InferenceTimingCollector:
71+
return _COLLECTOR
72+
73+
74+
# ──────────────────────────────────────────────────────────────────────────────
75+
# Image fixture
76+
# ──────────────────────────────────────────────────────────────────────────────
77+
78+
@pytest.fixture(scope="session")
79+
def indoor_image_bytes() -> bytes:
80+
path = ASSETS_DIR / "indoor_scene.jpg"
81+
assert path.exists(), (
82+
f"Test asset not found: {path}\n"
83+
"Run: curl -sL 'https://picsum.photos/id/534/640/480' "
84+
f"-o {path}"
85+
)
86+
return path.read_bytes()
87+
88+
89+
# ──────────────────────────────────────────────────────────────────────────────
90+
# Local-model-path fixture
91+
# ──────────────────────────────────────────────────────────────────────────────
92+
93+
@pytest.fixture(scope="session")
94+
def local_model_path(tmp_path_factory) -> str:
95+
"""
96+
Provide a .pth path in a temporary directory that is cleaned up after the
97+
session. If the model is already in the default vizion3d cache we symlink
98+
it (free); otherwise we download it fresh.
99+
"""
100+
from vizion3d.lifting.defaults import (
101+
DEFAULT_DEPTH_MODEL_FILENAME,
102+
DEFAULT_DEPTH_MODEL_URL,
103+
default_model_cache_dir,
104+
download_model,
105+
)
106+
107+
default_cache = default_model_cache_dir() / DEFAULT_DEPTH_MODEL_FILENAME
108+
tmp_dir = tmp_path_factory.mktemp("local_model")
109+
dest = tmp_dir / DEFAULT_DEPTH_MODEL_FILENAME
110+
111+
if default_cache.exists():
112+
dest.symlink_to(default_cache.resolve())
113+
else:
114+
download_model(DEFAULT_DEPTH_MODEL_URL, cache_dir=tmp_dir)
115+
116+
assert dest.exists() or dest.is_symlink(), f"Model not found at {dest}"
117+
return str(dest)
118+
119+
120+
# ──────────────────────────────────────────────────────────────────────────────
121+
# gRPC server + client stub fixture
122+
# ──────────────────────────────────────────────────────────────────────────────
123+
124+
_MAX_MSG = 500 * 1024 * 1024 # match server cap
125+
126+
_GRPC_OPTIONS = [
127+
("grpc.max_send_message_length", _MAX_MSG),
128+
("grpc.max_receive_message_length", _MAX_MSG),
129+
]
130+
131+
132+
@pytest.fixture(scope="session")
133+
def grpc_client_stub():
134+
"""
135+
Start a real gRPC server on a random port in a background thread pool and
136+
yield a connected LiftingService stub. Server is stopped after the session.
137+
"""
138+
from vizion3d.proto import lifting_pb2_grpc
139+
from vizion3d.server.grpc.server import LiftingServiceServicer
140+
141+
server = grpc.server(
142+
futures.ThreadPoolExecutor(max_workers=4),
143+
options=_GRPC_OPTIONS,
144+
)
145+
lifting_pb2_grpc.add_LiftingServiceServicer_to_server(
146+
LiftingServiceServicer(), server
147+
)
148+
port = server.add_insecure_port("[::]:0") # 0 → OS picks a free port
149+
server.start()
150+
151+
channel = grpc.insecure_channel(f"localhost:{port}", options=_GRPC_OPTIONS)
152+
stub = lifting_pb2_grpc.LiftingServiceStub(channel)
153+
154+
yield stub
155+
156+
channel.close()
157+
server.stop(grace=0)
158+
159+
160+
# ──────────────────────────────────────────────────────────────────────────────
161+
# Terminal report (hook)
162+
# ──────────────────────────────────────────────────────────────────────────────
163+
164+
def pytest_terminal_summary(terminalreporter, exitstatus, config): # noqa: ARG001
165+
records = _COLLECTOR.records
166+
if not records:
167+
return
168+
169+
W = 82
170+
EP = 10 # entry-point col width
171+
SC = 16 # scenario col width
172+
RN = 4 # run col width
173+
DU = 10 # duration col width
174+
ST = 20 # status col width
175+
176+
def _write(line: str = "") -> None:
177+
terminalreporter.write_line(line)
178+
179+
def _thick() -> None:
180+
_write("━" * W)
181+
182+
def _thin() -> None:
183+
_write(
184+
f" {'─'*EP}─┼─{'─'*SC}─┼─{'─'*RN}─┼─{'─'*(DU)}─┼─{'─'*ST}"
185+
)
186+
187+
def _row(ep="", sc="", run="", dur="", status="") -> None:
188+
_write(
189+
f" {ep:<{EP}}{sc:<{SC}}{run:^{RN}}{dur:>{DU}}{status}"
190+
)
191+
192+
_write()
193+
_thick()
194+
_write(f" {'VIZION3D · INTEGRATION INFERENCE TIMING REPORT':^{W - 4}}")
195+
_thick()
196+
_write()
197+
_row("Entry Point", "Scenario", "Run", "Duration", "Status")
198+
_thin()
199+
200+
def sort_key(r): return (r.entry_point, r.scenario, r.run)
201+
def group_key(r): return (r.entry_point, r.scenario)
202+
203+
first_loads: list[float] = []
204+
warm_times: list[float] = []
205+
206+
sorted_records = sorted(records, key=sort_key)
207+
groups = [
208+
(k, list(v))
209+
for k, v in groupby(sorted_records, key=group_key)
210+
]
211+
212+
for g_idx, ((ep, sc), recs) in enumerate(groups):
213+
if g_idx > 0:
214+
_thin()
215+
216+
recs = sorted(recs, key=lambda r: r.run)
217+
first_dur = recs[0].duration
218+
219+
for i, rec in enumerate(recs):
220+
ep_label = ep if i == 0 else ""
221+
sc_label = sc if i == 0 else ""
222+
dur_str = f"{rec.duration:7.3f}s"
223+
224+
if rec.run == 1:
225+
status = "◉ COLD LOAD"
226+
first_loads.append(rec.duration)
227+
else:
228+
pct = (1.0 - rec.duration / first_dur) * 100.0
229+
status = f"⚡ {pct:4.1f}% faster"
230+
warm_times.append(rec.duration)
231+
232+
_row(ep_label, sc_label, str(rec.run), dur_str, status)
233+
234+
_write()
235+
_thick()
236+
_write()
237+
238+
if first_loads and warm_times:
239+
avg_load = sum(first_loads) / len(first_loads)
240+
avg_warm = sum(warm_times) / len(warm_times)
241+
speedup = avg_load / avg_warm if avg_warm > 0 else float("inf")
242+
total = len(records)
243+
244+
pad = 42
245+
_write(f" {'SUMMARY'}")
246+
_write(f" {'─' * 58}")
247+
_write(f" {'Average cold-load time':<{pad}}: {avg_load:>7.3f}s (disk → memory)")
248+
_write(f" {'Average warm inference':<{pad}}: {avg_warm:>7.3f}s (model already in RAM)")
249+
_write(f" {'In-memory speedup':<{pad}}: {speedup:>6.1f}×")
250+
_write(f" {'Total inference runs':<{pad}}: {total}")
251+
252+
out_dirs = sorted({r.output_dir for r in records if r.output_dir})
253+
if out_dirs:
254+
_write(f" {'Output saved to':<{pad}}:")
255+
for d in out_dirs:
256+
_write(f" {d}")
257+
258+
_write()
259+
_thick()
260+
_write()

0 commit comments

Comments
 (0)