conftest.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. """Shared fixtures for service tests.
  2. Mostly FTP.
  3. Provides a real implicit FTPS server (via mock_ftp_server) and client factory
  4. for integration-style testing of BambuFTPClient against a live server.
  5. The server fixture is class-scoped to avoid the overhead of starting a new
  6. TLS server for every test (~67 TLS handshakes → ~9 per class).
  7. """
  8. import io
  9. import os
  10. import shutil
  11. import socket
  12. from unittest.mock import patch
  13. import pytest
  14. from backend.app.services.bambu_ftp import BambuFTPClient
  15. from backend.app.services.virtual_printer.certificate import CertificateService
  16. from backend.tests.unit.services.mock_ftp_server import MockBambuFTPServer
  17. BAMBU_DIRS = ("cache", "timelapse", "model", "data", "data/Metadata")
  18. @pytest.fixture(scope="session")
  19. def ftp_certs(tmp_path_factory):
  20. """Generate self-signed TLS certificates once per test session."""
  21. cert_dir = tmp_path_factory.mktemp("ftp_certs")
  22. svc = CertificateService(cert_dir, serial="TEST_FTP_SERVER")
  23. cert_path, key_path = svc.generate_certificates()
  24. return str(cert_path), str(key_path)
  25. def _find_free_port() -> int:
  26. """Find a free TCP port on localhost."""
  27. with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
  28. s.bind(("127.0.0.1", 0))
  29. return s.getsockname()[1]
  30. @pytest.fixture(scope="class")
  31. def ftp_root(tmp_path_factory):
  32. """Create temp directory with standard Bambu printer directory structure."""
  33. root = tmp_path_factory.mktemp("ftp_root")
  34. for d in BAMBU_DIRS:
  35. (root / d).mkdir(parents=True, exist_ok=True)
  36. return root
  37. @pytest.fixture(scope="class")
  38. def ftp_server(ftp_certs, ftp_root):
  39. """Start a mock implicit FTPS server, yield it, stop on cleanup."""
  40. cert_path, key_path = ftp_certs
  41. port = _find_free_port()
  42. server = MockBambuFTPServer(
  43. host="127.0.0.1",
  44. port=port,
  45. root_dir=str(ftp_root),
  46. cert_path=cert_path,
  47. key_path=key_path,
  48. access_code="12345678",
  49. )
  50. server.start()
  51. yield server
  52. server.stop()
  53. @pytest.fixture(autouse=True)
  54. def _ftp_test_cleanup(request):
  55. """Reset server state between tests within a class.
  56. Clears injected failures and restores the Bambu directory structure
  57. so each test starts with a clean filesystem. Skips cleanup for test
  58. classes that don't use the class-scoped ftp_server (e.g.
  59. TestDisconnectServerGone).
  60. """
  61. yield
  62. # Only clean up if this test class uses the class-scoped fixtures
  63. ftp_root = request.node.funcargs.get("ftp_root")
  64. if ftp_root is None:
  65. return
  66. server = request.node.funcargs.get("ftp_server")
  67. if server is not None:
  68. server.clear_failures()
  69. # Restore clean directory structure
  70. root = str(ftp_root)
  71. for entry in os.listdir(root):
  72. path = os.path.join(root, entry)
  73. if os.path.isdir(path):
  74. shutil.rmtree(path)
  75. else:
  76. os.remove(path)
  77. for d in BAMBU_DIRS:
  78. os.makedirs(os.path.join(root, d), exist_ok=True)
  79. @pytest.fixture()
  80. def ftp_client_factory(ftp_server):
  81. """Factory that creates BambuFTPClient instances pointed at the mock server."""
  82. def _make_client(
  83. printer_model: str = "X1C",
  84. force_prot_c: bool = False,
  85. access_code: str = "12345678",
  86. timeout: float = 10.0,
  87. ) -> BambuFTPClient:
  88. client = BambuFTPClient(
  89. ip_address="127.0.0.1",
  90. access_code=access_code,
  91. timeout=timeout,
  92. printer_model=printer_model,
  93. force_prot_c=force_prot_c,
  94. )
  95. # Override port to point at mock server
  96. client.FTP_PORT = ftp_server.port
  97. return client
  98. return _make_client
  99. @pytest.fixture(autouse=True)
  100. def clear_ftp_mode_cache():
  101. """Clear BambuFTPClient's per-printer caches before and after each test.
  102. Both are class-level dicts keyed by IP, and every test here talks to
  103. 127.0.0.1 — a handshake cool-off left behind by one test would make the
  104. next one's ``connect()`` return False without touching the server (#2780).
  105. """
  106. BambuFTPClient._mode_cache.clear()
  107. BambuFTPClient._handshake_blocked_until.clear()
  108. BambuFTPClient._handshake_skip_logged.clear()
  109. yield
  110. BambuFTPClient._mode_cache.clear()
  111. BambuFTPClient._handshake_blocked_until.clear()
  112. BambuFTPClient._handshake_skip_logged.clear()
  113. @pytest.fixture()
  114. def patch_ftp_port(ftp_server):
  115. """Patch FTP_PORT at class level for async wrapper tests.
  116. Async wrappers create their own BambuFTPClient instances internally,
  117. so we need to patch the class-level default port.
  118. """
  119. with patch.object(BambuFTPClient, "FTP_PORT", ftp_server.port):
  120. yield ftp_server
  121. @pytest.fixture()
  122. def distinct_surface_tones():
  123. """Count the distinct colours covering the model's surface in a render.
  124. Shared by the STL and plate thumbnail suites, which render the same way
  125. through two different modules and need the same question answered.
  126. Quantises to 5 bits per channel before counting and keeps only pixels where
  127. green dominates. The spread being quantised away is Agg's antialiasing and
  128. the alpha compositing; PNG itself is lossless and contributes none.
  129. **This counts large flat tone regions, which is only the same thing as
  130. "is it shaded" for a FLAT-FACED model.** A curved surface produces several
  131. such regions with no light at all — measured unshaded at alpha=0.9: cube 1,
  132. cylinder 1, but sphere 3 and torus 3. So the cube fixture is not incidental;
  133. swap in anything rounder and ``>= 3`` passes on completely unlit output.
  134. A cube is 1 unshaded and 3 lit, and its three margins are comfortable
  135. (0.35 / 0.35 / 0.29, nothing between the noise floor and the threshold).
  136. Note the green-dominant filter keeps the green-to-background blends along the
  137. silhouette as well as the model — about 1% of the pixels it counts. They sit
  138. far below ``min_share`` individually, so they change no verdict.
  139. """
  140. def _count(png: bytes, *, min_share: float = 0.02) -> int:
  141. import numpy as np
  142. from PIL import Image
  143. # np.asarray, not Image.getdata(): getdata is deprecated for removal in
  144. # Pillow 14 and requirements.txt pins pillow unbounded, while pyproject
  145. # silences DeprecationWarning — so it would surface as an AttributeError
  146. # in CI rather than as a warning anyone saw coming.
  147. rgb = np.asarray(Image.open(io.BytesIO(png)).convert("RGB"), dtype=np.int16)
  148. r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
  149. surface_mask = (g > r) & (g > b)
  150. if not surface_mask.any():
  151. return 0
  152. keys = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
  153. counts = np.bincount(keys[surface_mask].ravel())
  154. return int((counts / counts.sum() >= min_share).sum())
  155. return _count