"""Unit tests for services/floorplan_detector.py.""" import asyncio from datetime import datetime from unittest.mock import AsyncMock, patch, MagicMock from models.listing import RentListing, ListingSite, FurnishType from services.floorplan_detector import _calculate_sqm_ocr, detect_floorplan def _make_listing(**kwargs) -> RentListing: # type: ignore[no-untyped-def] defaults = dict( id=1, price=2000.0, number_of_bedrooms=2, square_meters=None, agency="Test", council_tax_band="C", longitude=0.0, latitude=0.0, price_history_json="[]", listing_site=ListingSite.RIGHTMOVE, last_seen=datetime.now(), photo_thumbnail=None, floorplan_image_paths=[], additional_info={"property": {"visible": True}}, routing_info_json=None, furnish_type=FurnishType.FURNISHED, available_from=None, ) defaults.update(kwargs) return RentListing(**defaults) class TestCalculateSqmOcr: async def test_skips_listing_with_existing_square_meters(self) -> None: listing = _make_listing(square_meters=50.0) semaphore = asyncio.Semaphore(1) result = await _calculate_sqm_ocr(listing, semaphore) assert result is None async def test_empty_floorplan_paths_returns_listing_with_zero(self) -> None: listing = _make_listing(floorplan_image_paths=[]) semaphore = asyncio.Semaphore(1) result = await _calculate_sqm_ocr(listing, semaphore) assert result is not None assert result.square_meters == 0 @patch("services.floorplan_detector.floorplan") async def test_with_mocked_ocr_returning_value(self, mock_floorplan: MagicMock) -> None: mock_floorplan.calculate_ocr.return_value = (85.0, "Total: 85 sq m") listing = _make_listing(floorplan_image_paths=["/fake/path.png"]) semaphore = asyncio.Semaphore(1) result = await _calculate_sqm_ocr(listing, semaphore) assert result is not None assert result.square_meters == 85.0 @patch("services.floorplan_detector.floorplan") async def test_with_mocked_ocr_returning_none(self, mock_floorplan: MagicMock) -> None: mock_floorplan.calculate_ocr.return_value = (None, "no data") listing = _make_listing(floorplan_image_paths=["/fake/path.png"]) semaphore = asyncio.Semaphore(1) result = await _calculate_sqm_ocr(listing, semaphore) assert result is not None assert result.square_meters == 0 @patch("services.floorplan_detector.floorplan") async def test_picks_max_from_multiple_floorplans(self, mock_floorplan: MagicMock) -> None: mock_floorplan.calculate_ocr.side_effect = [ (50.0, "50 sq m"), (90.0, "90 sq m"), ] listing = _make_listing(floorplan_image_paths=["/fake/a.png", "/fake/b.png"]) semaphore = asyncio.Semaphore(2) result = await _calculate_sqm_ocr(listing, semaphore) assert result is not None assert result.square_meters == 90.0 class TestDetectFloorplan: @patch("services.floorplan_detector.floorplan") async def test_detect_floorplan_with_mocked_repository(self, mock_floorplan: MagicMock) -> None: mock_floorplan.calculate_ocr.return_value = (75.0, "75 sq m") listing = _make_listing( floorplan_image_paths=["/fake/path.png"], ) repository = MagicMock() repository.get_listings = AsyncMock(return_value=[listing]) repository.upsert_listings = AsyncMock(return_value=[]) await detect_floorplan(repository) repository.upsert_listings.assert_called_once() upserted = repository.upsert_listings.call_args[0][0] assert len(upserted) == 1 assert upserted[0].square_meters == 75.0 async def test_detect_floorplan_skips_already_processed(self) -> None: listing = _make_listing(square_meters=50.0) repository = MagicMock() repository.get_listings = AsyncMock(return_value=[listing]) repository.upsert_listings = AsyncMock(return_value=[]) await detect_floorplan(repository) repository.upsert_listings.assert_called_once() upserted = repository.upsert_listings.call_args[0][0] assert len(upserted) == 0