mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
194 lines
6.7 KiB
Python
194 lines
6.7 KiB
Python
"""Shared fixtures for service tests.
|
|
|
|
Mostly FTP.
|
|
|
|
Provides a real implicit FTPS server (via mock_ftp_server) and client factory
|
|
for integration-style testing of BambuFTPClient against a live server.
|
|
|
|
The server fixture is class-scoped to avoid the overhead of starting a new
|
|
TLS server for every test (~67 TLS handshakes → ~9 per class).
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import shutil
|
|
import socket
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.bambu_ftp import BambuFTPClient
|
|
from backend.app.services.virtual_printer.certificate import CertificateService
|
|
from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
|
|
|
|
BAMBU_DIRS = ("cache", "timelapse", "model", "data", "data/Metadata")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def ftp_certs(tmp_path_factory):
|
|
"""Generate self-signed TLS certificates once per test session."""
|
|
cert_dir = tmp_path_factory.mktemp("ftp_certs")
|
|
svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
|
|
cert_path, key_path = svc.generate_certificates()
|
|
return str(cert_path), str(key_path)
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
"""Find a free TCP port on localhost."""
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_root(tmp_path_factory):
|
|
"""Create temp directory with standard Bambu printer directory structure."""
|
|
root = tmp_path_factory.mktemp("ftp_root")
|
|
for d in BAMBU_DIRS:
|
|
(root / d).mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
@pytest.fixture(scope="class")
|
|
def ftp_server(ftp_certs, ftp_root):
|
|
"""Start a mock implicit FTPS server, yield it, stop on cleanup."""
|
|
cert_path, key_path = ftp_certs
|
|
port = _find_free_port()
|
|
server = MockBambuFTPServer(
|
|
host="127.0.0.1",
|
|
port=port,
|
|
root_dir=str(ftp_root),
|
|
cert_path=cert_path,
|
|
key_path=key_path,
|
|
access_code="12345678",
|
|
)
|
|
server.start()
|
|
yield server
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ftp_test_cleanup(request):
|
|
"""Reset server state between tests within a class.
|
|
|
|
Clears injected failures and restores the Bambu directory structure
|
|
so each test starts with a clean filesystem. Skips cleanup for test
|
|
classes that don't use the class-scoped ftp_server (e.g.
|
|
TestDisconnectServerGone).
|
|
"""
|
|
yield
|
|
# Only clean up if this test class uses the class-scoped fixtures
|
|
ftp_root = request.node.funcargs.get("ftp_root")
|
|
if ftp_root is None:
|
|
return
|
|
server = request.node.funcargs.get("ftp_server")
|
|
if server is not None:
|
|
server.clear_failures()
|
|
# Restore clean directory structure
|
|
root = str(ftp_root)
|
|
for entry in os.listdir(root):
|
|
path = os.path.join(root, entry)
|
|
if os.path.isdir(path):
|
|
shutil.rmtree(path)
|
|
else:
|
|
os.remove(path)
|
|
for d in BAMBU_DIRS:
|
|
os.makedirs(os.path.join(root, d), exist_ok=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def ftp_client_factory(ftp_server):
|
|
"""Factory that creates BambuFTPClient instances pointed at the mock server."""
|
|
|
|
def _make_client(
|
|
printer_model: str = "X1C",
|
|
force_prot_c: bool = False,
|
|
access_code: str = "12345678",
|
|
timeout: float = 10.0,
|
|
) -> BambuFTPClient:
|
|
client = BambuFTPClient(
|
|
ip_address="127.0.0.1",
|
|
access_code=access_code,
|
|
timeout=timeout,
|
|
printer_model=printer_model,
|
|
force_prot_c=force_prot_c,
|
|
)
|
|
# Override port to point at mock server
|
|
client.FTP_PORT = ftp_server.port
|
|
return client
|
|
|
|
return _make_client
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_ftp_mode_cache():
|
|
"""Clear BambuFTPClient's per-printer caches before and after each test.
|
|
|
|
Both are class-level dicts keyed by IP, and every test here talks to
|
|
127.0.0.1 — a handshake cool-off left behind by one test would make the
|
|
next one's ``connect()`` return False without touching the server (#2780).
|
|
"""
|
|
BambuFTPClient._mode_cache.clear()
|
|
BambuFTPClient._handshake_blocked_until.clear()
|
|
BambuFTPClient._handshake_skip_logged.clear()
|
|
yield
|
|
BambuFTPClient._mode_cache.clear()
|
|
BambuFTPClient._handshake_blocked_until.clear()
|
|
BambuFTPClient._handshake_skip_logged.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def patch_ftp_port(ftp_server):
|
|
"""Patch FTP_PORT at class level for async wrapper tests.
|
|
|
|
Async wrappers create their own BambuFTPClient instances internally,
|
|
so we need to patch the class-level default port.
|
|
"""
|
|
with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
|
|
yield ftp_server
|
|
|
|
|
|
@pytest.fixture()
|
|
def distinct_surface_tones():
|
|
"""Count the distinct colours covering the model's surface in a render.
|
|
|
|
Shared by the STL and plate thumbnail suites, which render the same way
|
|
through two different modules and need the same question answered.
|
|
|
|
Quantises to 5 bits per channel before counting and keeps only pixels where
|
|
green dominates. The spread being quantised away is Agg's antialiasing and
|
|
the alpha compositing; PNG itself is lossless and contributes none.
|
|
|
|
**This counts large flat tone regions, which is only the same thing as
|
|
"is it shaded" for a FLAT-FACED model.** A curved surface produces several
|
|
such regions with no light at all — measured unshaded at alpha=0.9: cube 1,
|
|
cylinder 1, but sphere 3 and torus 3. So the cube fixture is not incidental;
|
|
swap in anything rounder and ``>= 3`` passes on completely unlit output.
|
|
A cube is 1 unshaded and 3 lit, and its three margins are comfortable
|
|
(0.35 / 0.35 / 0.29, nothing between the noise floor and the threshold).
|
|
|
|
Note the green-dominant filter keeps the green-to-background blends along the
|
|
silhouette as well as the model — about 1% of the pixels it counts. They sit
|
|
far below ``min_share`` individually, so they change no verdict.
|
|
"""
|
|
|
|
def _count(png: bytes, *, min_share: float = 0.02) -> int:
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
# np.asarray, not Image.getdata(): getdata is deprecated for removal in
|
|
# Pillow 14 and requirements.txt pins pillow unbounded, while pyproject
|
|
# silences DeprecationWarning — so it would surface as an AttributeError
|
|
# in CI rather than as a warning anyone saw coming.
|
|
rgb = np.asarray(Image.open(io.BytesIO(png)).convert("RGB"), dtype=np.int16)
|
|
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
|
surface_mask = (g > r) & (g > b)
|
|
if not surface_mask.any():
|
|
return 0
|
|
|
|
keys = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
|
|
counts = np.bincount(keys[surface_mask].ravel())
|
|
return int((counts / counts.sum() >= min_share).sum())
|
|
|
|
return _count
|