| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- """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
|