import initial

This commit is contained in:
Vincent Bourdon
2026-06-10 10:21:18 +02:00
commit 5a03f8a38d
59 changed files with 4777 additions and 0 deletions
View File
View File
+113
View File
@@ -0,0 +1,113 @@
import pytest
from pathlib import Path
from logimage.application.use_cases.generate_puzzles import (
GeneratePuzzlesRequest,
GeneratePuzzlesUseCase,
)
from tests.fakes.fakes import FakeImageSource, FakeImageConverter, FakePdfExporter
def _make_use_case() -> tuple[GeneratePuzzlesUseCase, FakeImageSource, FakeImageConverter, FakePdfExporter]:
source = FakeImageSource()
converter = FakeImageConverter()
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, converter, exporter)
return use_case, source, converter, exporter
def test_generate_single_puzzle_calls_fetch_once() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest())
assert len(source.fetch_calls) == 1
def test_generate_count_5_calls_fetch_5_times() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(count=5))
assert len(source.fetch_calls) == 5
def test_theme_forwarded_to_source() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(theme="cats"))
assert source.fetch_calls[0] == "cats"
def test_no_theme_passes_none_to_source() -> None:
use_case, source, _, _ = _make_use_case()
use_case.execute(GeneratePuzzlesRequest())
assert source.fetch_calls[0] is None
def test_solution_flag_forwarded_to_exporter() -> None:
use_case, _, _, exporter = _make_use_case()
use_case.execute(GeneratePuzzlesRequest(with_solution=True))
assert exporter.last_with_solution is True
def test_output_path_forwarded_to_exporter() -> None:
use_case, _, _, exporter = _make_use_case()
path = Path("my_output.pdf")
use_case.execute(GeneratePuzzlesRequest(output_path=path))
assert exporter.last_path == path
def test_difficulty_easy_uses_10x10(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="easy"))
assert sizes_used[0] == (10, 10)
def test_difficulty_hard_uses_20x20(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="hard"))
assert sizes_used[0] == (20, 20)
def test_custom_size_overrides_difficulty(monkeypatch: pytest.MonkeyPatch) -> None:
source = FakeImageSource()
exporter = FakePdfExporter()
sizes_used: list[tuple[int, int]] = []
class CapturingConverter(FakeImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int): # type: ignore[override]
sizes_used.append((width, height))
return super().to_grid(image_bytes, width, height)
use_case = GeneratePuzzlesUseCase(source, CapturingConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest(difficulty="easy", size=(25, 30)))
assert sizes_used[0] == (25, 30)
def test_puzzle_title_comes_from_image_source() -> None:
source = FakeImageSource(title="Beautiful Cat")
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest())
assert exporter.exported_puzzles[0].title == "Beautiful Cat"
def test_puzzle_image_bytes_comes_from_image_source() -> None:
source = FakeImageSource(content=b"real-image-bytes")
exporter = FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest())
assert exporter.exported_puzzles[0].image_bytes == b"real-image-bytes"
View File
+96
View File
@@ -0,0 +1,96 @@
from pathlib import Path
import pytest
from PIL import Image
from unittest.mock import patch
from logimage.cli.main import main
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
def _make_png_file(tmp_path: Path) -> Path:
img = Image.new("RGB", (50, 50), color=(200, 100, 50))
path = tmp_path / "icon.png"
img.save(path, format="PNG")
return path
def test_pexels_key_uses_pexels_source(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_unsplash_key_uses_unsplash_source(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], UnsplashImageSource)
def test_pexels_takes_priority_over_unsplash(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], PexelsImageSource)
def test_local_image_flag_uses_local_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
icon = _make_png_file(tmp_path)
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--local-image", str(icon)]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], LocalFileImageSource)
def test_local_image_takes_priority_over_api_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
icon = _make_png_file(tmp_path)
monkeypatch.setenv("PEXELS_API_KEY", "pexels-test-key")
monkeypatch.setenv("UNSPLASH_ACCESS_KEY", "unsplash-test-key")
with patch("logimage.cli.main.GeneratePuzzlesUseCase") as mock_uc, \
patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--local-image", str(icon)]):
mock_uc.return_value.execute.return_value = None
main()
assert isinstance(mock_uc.call_args.kwargs["image_source"], LocalFileImageSource)
def test_no_key_exits_with_error(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None:
monkeypatch.delenv("PEXELS_API_KEY", raising=False)
monkeypatch.delenv("UNSPLASH_ACCESS_KEY", raising=False)
with patch("logimage.cli.main.load_dotenv"), \
patch("sys.argv", ["logimage", "--count", "1"]):
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "PEXELS_API_KEY" in captured.err
assert "UNSPLASH_ACCESS_KEY" in captured.err
View File
View File
+34
View File
@@ -0,0 +1,34 @@
import pytest
from logimage.domain.value_objects.clue import Clue
def test_clue_from_consecutive_cells() -> None:
cells = (True, True, False, True, False, False, True, True, True)
clue = Clue.from_row(cells)
assert clue.values == (2, 1, 3)
def test_clue_from_empty_row() -> None:
clue = Clue.from_row((False, False, False))
assert clue.values == ()
def test_clue_from_single_true_cell() -> None:
clue = Clue.from_row((False, True, False))
assert clue.values == (1,)
def test_clue_from_all_true_row() -> None:
clue = Clue.from_row((True, True, True))
assert clue.values == (3,)
def test_clue_is_immutable() -> None:
clue = Clue.from_row((True, False, True))
with pytest.raises(AttributeError):
clue.values = (99,) # type: ignore[misc]
def test_clue_trailing_true() -> None:
clue = Clue.from_row((False, True, True))
assert clue.values == (2,)
+57
View File
@@ -0,0 +1,57 @@
import pytest
from logimage.domain.value_objects.grid import Grid
def _make_cells(rows: int, cols: int, value: bool = False) -> list[list[bool]]:
return [[value] * cols for _ in range(rows)]
def test_grid_width_and_height() -> None:
grid = Grid.from_list(_make_cells(10, 15))
assert grid.width == 15
assert grid.height == 10
def test_grid_row_returns_correct_values() -> None:
cells5 = [[True, False, True, False, True]] * 5
grid = Grid.from_list(cells5)
assert grid.row(0) == (True, False, True, False, True)
def test_grid_col_returns_correct_values() -> None:
cells = [[True, False, False, False, False]] * 5
grid = Grid.from_list(cells)
assert grid.col(0) == (True, True, True, True, True)
assert grid.col(1) == (False, False, False, False, False)
def test_grid_rejects_width_below_minimum() -> None:
with pytest.raises(ValueError, match="width"):
Grid.from_list([[True, False, True, False]] * 5)
def test_grid_rejects_height_below_minimum() -> None:
with pytest.raises(ValueError, match="height"):
Grid.from_list([[True] * 5] * 4)
def test_grid_rejects_width_above_maximum() -> None:
with pytest.raises(ValueError, match="width"):
Grid.from_list([[True] * 51] * 5)
def test_grid_rejects_height_above_maximum() -> None:
with pytest.raises(ValueError, match="height"):
Grid.from_list([[True] * 5] * 51)
def test_grid_is_immutable() -> None:
grid = Grid.from_list(_make_cells(5, 5))
with pytest.raises(AttributeError):
grid.cells = () # type: ignore[misc]
def test_grid_from_list_converts_to_tuples() -> None:
grid = Grid.from_list(_make_cells(5, 5, True))
assert isinstance(grid.cells, tuple)
assert isinstance(grid.cells[0], tuple)
+59
View File
@@ -0,0 +1,59 @@
from logimage.domain.value_objects.grid import Grid
from logimage.domain.value_objects.clue import Clue
from logimage.domain.entities.puzzle import NonogramPuzzle
def _simple_grid() -> Grid:
return Grid.from_list([
[True, False, True, False, True ],
[False, True, False, True, False],
[True, True, False, False, True ],
[False, False, True, True, False],
[True, False, False, True, True ],
])
def test_puzzle_row_clues_computed_correctly() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.row_clues[0] == Clue(values=(1, 1, 1))
assert puzzle.row_clues[1] == Clue(values=(1, 1))
assert puzzle.row_clues[2] == Clue(values=(2, 1))
def test_puzzle_col_clues_computed_correctly() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.col_clues[0] == Clue(values=(1, 1, 1))
assert puzzle.col_clues[1] == Clue(values=(2,))
def test_puzzle_title_stored() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Mountains")
assert puzzle.title == "Mountains"
def test_puzzle_has_correct_grid() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert puzzle.grid is grid
def test_puzzle_row_clue_count_matches_grid_height() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert len(puzzle.row_clues) == grid.height
def test_puzzle_col_clue_count_matches_grid_width() -> None:
grid = _simple_grid()
puzzle = NonogramPuzzle.from_grid(grid, "Test")
assert len(puzzle.col_clues) == grid.width
def test_puzzle_image_bytes_defaults_to_none() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test")
assert puzzle.image_bytes is None
def test_puzzle_image_bytes_stored() -> None:
puzzle = NonogramPuzzle.from_grid(_simple_grid(), "Test", b"fake-image-bytes")
assert puzzle.image_bytes == b"fake-image-bytes"
View File
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.ports.pdf_exporter import PdfExporter
from logimage.domain.value_objects.image_data import ImageData
from logimage.domain.value_objects.grid import Grid
from logimage.domain.entities.puzzle import NonogramPuzzle
_MINIMAL_CELLS = [[i % 2 == 0] * 5 for i in range(5)]
MINIMAL_GRID = Grid.from_list(_MINIMAL_CELLS)
class FakeImageSource(ImageSource):
def __init__(
self,
content: bytes = b"fake",
title: str = "Fake Puzzle",
) -> None:
self._content = content
self._title = title
self.fetch_calls: list[str | None] = []
def fetch(self, theme: str | None = None) -> ImageData:
self.fetch_calls.append(theme)
return ImageData(content=self._content, title=self._title)
class FakeImageConverter(ImageConverter):
def __init__(self, grid: Grid = MINIMAL_GRID) -> None:
self._grid = grid
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid:
return self._grid
class FakePdfExporter(PdfExporter):
def __init__(self) -> None:
self.exported_puzzles: list[NonogramPuzzle] = []
self.last_path: Path | None = None
self.last_with_solution: bool = False
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None:
self.exported_puzzles = list(puzzles)
self.last_path = path
self.last_with_solution = with_solution
+11
View File
@@ -0,0 +1,11 @@
def count_blocks(line: tuple[bool, ...]) -> int:
blocks = 0
in_block = False
for cell in line:
if cell:
if not in_block:
blocks += 1
in_block = True
else:
in_block = False
return blocks
View File
@@ -0,0 +1,35 @@
import pytest
from pathlib import Path
from PIL import Image
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
from logimage.domain.value_objects.image_data import ImageData
def test_fetch_returns_image_data(tmp_path: Path) -> None:
img = Image.new("RGB", (50, 50), color=(100, 150, 200))
path = tmp_path / "test_icon.png"
img.save(path, format="PNG")
source = LocalFileImageSource(path)
result = source.fetch()
assert isinstance(result, ImageData)
assert result.content == path.read_bytes()
assert result.title == "test_icon"
def test_fetch_title_is_stem(tmp_path: Path) -> None:
img = Image.new("RGB", (10, 10))
path = tmp_path / "my_cool_image.jpg"
img.save(path, format="JPEG")
source = LocalFileImageSource(path)
result = source.fetch()
assert result.title == "my_cool_image"
def test_fetch_raises_if_file_not_found() -> None:
source = LocalFileImageSource(Path("/nonexistent/image.png"))
with pytest.raises(FileNotFoundError):
source.fetch()
@@ -0,0 +1,28 @@
import os
import pytest
from logimage.infrastructure.image.pexels_source import PexelsImageSource
from logimage.domain.value_objects.image_data import ImageData
@pytest.mark.integration
def test_fetch_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch()
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
@pytest.mark.integration
def test_fetch_with_theme_returns_image_data() -> None:
api_key = os.environ.get("PEXELS_API_KEY")
if not api_key:
pytest.skip("PEXELS_API_KEY not set")
source = PexelsImageSource(api_key=api_key)
result = source.fetch(theme="forest")
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
@@ -0,0 +1,92 @@
import io
from PIL import Image, ImageDraw
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.domain.value_objects.grid import Grid
from tests.helpers import count_blocks
def _make_jpeg_bytes(width: int = 100, height: int = 100, color: tuple[int, int, int] = (128, 64, 192)) -> bytes:
img = Image.new("RGB", (width, height), color=color)
buf = io.BytesIO()
img.save(buf, format="JPEG")
return buf.getvalue()
def _make_silhouette_bytes(width: int = 100, height: int = 100) -> bytes:
"""Image avec un carré noir centré sur fond blanc."""
img = Image.new("RGB", (width, height), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
margin = width // 4
draw.rectangle([margin, margin, width - margin, height - margin], fill=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def test_output_grid_has_correct_dimensions() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10)
assert grid.width == 10
assert grid.height == 10
def test_output_is_grid_instance() -> None:
converter = PillowImageConverter()
result = converter.to_grid(_make_jpeg_bytes(), width=15, height=15)
assert isinstance(result, Grid)
def test_output_cells_are_booleans() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_jpeg_bytes(), width=10, height=10)
for row_idx in range(grid.height):
for cell in grid.row(row_idx):
assert isinstance(cell, bool)
def test_black_image_produces_all_true_cells() -> None:
img = Image.new("RGB", (100, 100), color=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="JPEG")
converter = PillowImageConverter()
grid = converter.to_grid(buf.getvalue(), width=10, height=10)
total_true = sum(cell for row in range(grid.height) for cell in grid.row(row))
assert total_true > 0
def test_white_image_produces_mostly_false_cells() -> None:
img = Image.new("RGB", (100, 100), color=(255, 255, 255))
buf = io.BytesIO()
img.save(buf, format="JPEG")
converter = PillowImageConverter()
grid = converter.to_grid(buf.getvalue(), width=10, height=10)
total_true = sum(cell for row in range(grid.height) for cell in grid.row(row))
assert total_true < grid.width * grid.height
def test_max_clue_blocks_respected_on_silhouette() -> None:
"""Une silhouette simple doit produire ≤ max_clue_blocks blocs par ligne/colonne."""
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(_make_silhouette_bytes(), width=20, height=20)
for i in range(grid.height):
blocks = count_blocks(grid.row(i))
assert blocks <= 6, f"Ligne {i} a {blocks} blocs (max=6)"
for j in range(grid.width):
blocks = count_blocks(grid.col(j))
assert blocks <= 6, f"Colonne {j} a {blocks} blocs (max=6)"
def test_custom_max_clue_blocks() -> None:
"""max_clue_blocks=3 produit une grille encore plus simple."""
converter = PillowImageConverter(max_clue_blocks=3)
grid = converter.to_grid(_make_silhouette_bytes(), width=15, height=15)
for i in range(grid.height):
assert count_blocks(grid.row(i)) <= 3
for j in range(grid.width):
assert count_blocks(grid.col(j)) <= 3
def test_accepts_png_bytes() -> None:
converter = PillowImageConverter()
grid = converter.to_grid(_make_silhouette_bytes(), width=10, height=10)
assert grid.width == 10
@@ -0,0 +1,51 @@
import io
from pathlib import Path
from PIL import Image, ImageDraw
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
from tests.helpers import count_blocks
def _make_icon_bytes() -> bytes:
"""Maison simple : rectangle + triangle sur fond blanc."""
img = Image.new("RGB", (100, 100), color=(255, 255, 255))
draw = ImageDraw.Draw(img)
# Corps de la maison
draw.rectangle([25, 50, 75, 90], fill=(0, 0, 0))
# Toit (triangle approximatif)
draw.polygon([(50, 10), (20, 50), (80, 50)], fill=(0, 0, 0))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def test_house_icon_produces_playable_grid() -> None:
"""Une icône maison simple doit donner une grille jouable (≤ 6 blocs)."""
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(_make_icon_bytes(), width=20, height=20)
for i in range(grid.height):
blocks = count_blocks(grid.row(i))
assert blocks <= 6, f"Ligne {i} a {blocks} blocs"
for j in range(grid.width):
blocks = count_blocks(grid.col(j))
assert blocks <= 6, f"Colonne {j} a {blocks} blocs"
def test_local_source_feeds_converter(tmp_path: Path) -> None:
"""LocalFileImageSource + PillowImageConverter produisent une grille valide."""
icon_path = tmp_path / "house.png"
icon_path.write_bytes(_make_icon_bytes())
source = LocalFileImageSource(icon_path)
image_data = source.fetch()
converter = PillowImageConverter(max_clue_blocks=6)
grid = converter.to_grid(image_data.content, width=15, height=15)
assert len(image_data.content) > 0
assert grid.width == 15
assert grid.height == 15
total_filled = sum(cell for r in range(grid.height) for cell in grid.row(r))
assert total_filled > 0, "Pipeline should produce some filled cells for house icon"
@@ -0,0 +1,80 @@
import io
import tempfile
from pathlib import Path
from PIL import Image as PilImage
from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.value_objects.grid import Grid
def _make_puzzle(rows: int = 10, cols: int = 10, title: str = "Test") -> NonogramPuzzle:
cells = [[i % 3 == 0] * cols for i in range(rows)]
grid = Grid.from_list(cells)
return NonogramPuzzle.from_grid(grid, title)
def test_export_creates_pdf_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "output.pdf"
exporter.export([_make_puzzle()], path)
assert path.exists()
assert path.stat().st_size > 1000
def test_export_multiple_puzzles_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path_single = Path(tmp) / "single.pdf"
path_multi = Path(tmp) / "multi.pdf"
exporter.export([_make_puzzle()], path_single)
exporter.export([_make_puzzle(), _make_puzzle(), _make_puzzle()], path_multi)
assert path_multi.stat().st_size > path_single.stat().st_size
def test_export_with_solution_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path_no_sol = Path(tmp) / "no_solution.pdf"
path_with_sol = Path(tmp) / "with_solution.pdf"
puzzle = _make_puzzle()
exporter.export([puzzle], path_no_sol, with_solution=False)
exporter.export([puzzle], path_with_sol, with_solution=True)
assert path_with_sol.stat().st_size > path_no_sol.stat().st_size
def test_export_starts_with_pdf_magic_bytes() -> None:
exporter = ReportLabPdfExporter()
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "output.pdf"
exporter.export([_make_puzzle()], path)
assert path.read_bytes()[:4] == b"%PDF"
def _make_puzzle_with_image(rows: int = 10, cols: int = 10) -> NonogramPuzzle:
buf = io.BytesIO()
PilImage.new("RGB", (40, 40), color=(100, 150, 200)).save(buf, format="JPEG")
cells = [[i % 3 == 0] * cols for i in range(rows)]
grid = Grid.from_list(cells)
return NonogramPuzzle.from_grid(grid, "Test With Image", buf.getvalue())
def test_solution_with_image_bytes_creates_larger_file() -> None:
exporter = ReportLabPdfExporter()
puzzle_no_img = _make_puzzle()
puzzle_with_img = _make_puzzle_with_image()
with tempfile.TemporaryDirectory() as tmp:
path_no_img = Path(tmp) / "no_img.pdf"
path_with_img = Path(tmp) / "with_img.pdf"
exporter.export([puzzle_no_img], path_no_img, with_solution=True)
exporter.export([puzzle_with_img], path_with_img, with_solution=True)
assert path_with_img.stat().st_size > path_no_img.stat().st_size
def test_solution_without_image_bytes_still_produces_valid_pdf() -> None:
exporter = ReportLabPdfExporter()
puzzle = _make_puzzle() # image_bytes is None
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "sol.pdf"
exporter.export([puzzle], path, with_solution=True)
assert path.read_bytes()[:4] == b"%PDF"
@@ -0,0 +1,27 @@
import os
import pytest
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.domain.value_objects.image_data import ImageData
@pytest.mark.integration
def test_fetch_returns_image_data() -> None:
api_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not api_key:
pytest.skip("UNSPLASH_ACCESS_KEY not set")
source = UnsplashImageSource(api_key=api_key)
result = source.fetch()
assert isinstance(result, ImageData)
assert len(result.content) > 1000
assert isinstance(result.title, str)
@pytest.mark.integration
def test_fetch_with_theme_returns_image_data() -> None:
api_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not api_key:
pytest.skip("UNSPLASH_ACCESS_KEY not set")
source = UnsplashImageSource(api_key=api_key)
result = source.fetch(theme="forest")
assert isinstance(result, ImageData)
assert len(result.content) > 1000