Files
logimage-generator/docs/superpowers/plans/2026-05-20-logimage-generator.md
Vincent Bourdon 5a03f8a38d import initial
2026-06-10 10:21:18 +02:00

1471 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Logimage Generator Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Python CLI that fetches images from Unsplash, converts them into nonogram puzzles, and generates a printable A4 PDF.
**Architecture:** Clean Architecture with 4 layers (domain → application → infrastructure → cli). The domain has zero external dependencies; ports (ABCs) decouple infrastructure from business logic. TDD throughout: domain and application tests are pure/fast; infrastructure tests are integration-marked.
**Tech Stack:** Python 3.11+, Pillow, opencv-python-headless, reportlab, httpx, python-dotenv, pytest, ruff, mypy.
---
## File Map
```
src/logimage/
├── __init__.py
├── domain/
│ ├── __init__.py
│ ├── value_objects/
│ │ ├── __init__.py
│ │ ├── clue.py
│ │ ├── grid.py
│ │ └── image_data.py
│ ├── entities/
│ │ ├── __init__.py
│ │ └── puzzle.py
│ └── ports/
│ ├── __init__.py
│ ├── image_source.py
│ ├── image_converter.py
│ └── pdf_exporter.py
├── application/
│ ├── __init__.py
│ └── use_cases/
│ ├── __init__.py
│ └── generate_puzzles.py
├── infrastructure/
│ ├── __init__.py
│ ├── image/
│ │ ├── __init__.py
│ │ ├── unsplash_source.py
│ │ └── pillow_converter.py
│ └── pdf/
│ ├── __init__.py
│ └── reportlab_exporter.py
└── cli/
├── __init__.py
└── main.py
tests/
├── conftest.py
├── domain/
│ ├── test_clue.py
│ ├── test_grid.py
│ └── test_puzzle.py
├── application/
│ └── test_generate_puzzles.py
├── infrastructure/
│ ├── test_pillow_converter.py
│ ├── test_unsplash_source.py
│ └── test_reportlab_exporter.py
└── fakes/
├── __init__.py
└── fakes.py
pyproject.toml
.env.example
.gitignore
```
---
## Task 1: Project scaffold
**Files:**
- Create: `pyproject.toml`
- Create: `src/logimage/__init__.py` (and all `__init__.py` stubs)
- Create: `tests/conftest.py`
- Create: `.gitignore`
- Create: `.env.example`
- [ ] **Step 1: Create `pyproject.toml`**
```toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "logimage-generator"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"Pillow>=10.0",
"opencv-python-headless>=4.8",
"reportlab>=4.0",
"httpx>=0.27",
"python-dotenv>=1.0",
"numpy>=1.26",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.4", "mypy>=1.10"]
[project.scripts]
logimage = "logimage.cli.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["integration: tests requiring network or external resources"]
[tool.ruff]
line-length = 88
[tool.mypy]
mypy_path = "src"
strict = true
```
- [ ] **Step 2: Create all `__init__.py` stubs and directory structure**
```bash
mkdir -p src/logimage/{domain/{value_objects,entities,ports},application/use_cases,infrastructure/{image,pdf},cli}
mkdir -p tests/{domain,application,infrastructure,fakes}
touch src/logimage/__init__.py
touch src/logimage/domain/__init__.py
touch src/logimage/domain/value_objects/__init__.py
touch src/logimage/domain/entities/__init__.py
touch src/logimage/domain/ports/__init__.py
touch src/logimage/application/__init__.py
touch src/logimage/application/use_cases/__init__.py
touch src/logimage/infrastructure/__init__.py
touch src/logimage/infrastructure/image/__init__.py
touch src/logimage/infrastructure/pdf/__init__.py
touch src/logimage/cli/__init__.py
touch tests/__init__.py
touch tests/domain/__init__.py
touch tests/application/__init__.py
touch tests/infrastructure/__init__.py
touch tests/fakes/__init__.py
```
- [ ] **Step 3: Create `tests/conftest.py`**
```python
import pytest
```
- [ ] **Step 4: Create `.gitignore`**
```
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
.venv/
venv/
.env
.coverage
htmlcov/
.mypy_cache/
.ruff_cache/
.superpowers/
*.pdf
```
- [ ] **Step 5: Create `.env.example`**
```
UNSPLASH_ACCESS_KEY=your_key_here
```
- [ ] **Step 6: Install dependencies and verify pytest runs**
```bash
pip install -e ".[dev]"
pytest
```
Expected: `no tests ran` (0 errors, 0 failures).
- [ ] **Step 7: Commit**
```bash
git add pyproject.toml .gitignore .env.example src/ tests/
git commit -m "chore: scaffold project structure"
```
---
## Task 2: `Clue` value object
**Files:**
- Create: `src/logimage/domain/value_objects/clue.py`
- Create: `tests/domain/test_clue.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/domain/test_clue.py
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,)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/domain/test_clue.py -v
```
Expected: `ModuleNotFoundError` or `ImportError`.
- [ ] **Step 3: Implement `Clue`**
```python
# src/logimage/domain/value_objects/clue.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Clue:
values: tuple[int, ...]
@classmethod
def from_row(cls, cells: tuple[bool, ...]) -> "Clue":
groups: list[int] = []
count = 0
for cell in cells:
if cell:
count += 1
elif count > 0:
groups.append(count)
count = 0
if count > 0:
groups.append(count)
return cls(values=tuple(groups))
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/domain/test_clue.py -v
```
Expected: 6 passed.
- [ ] **Step 5: Commit**
```bash
git add src/logimage/domain/value_objects/clue.py tests/domain/test_clue.py
git commit -m "feat(domain): add Clue value object"
```
---
## Task 3: `Grid` value object
**Files:**
- Create: `src/logimage/domain/value_objects/grid.py`
- Create: `tests/domain/test_grid.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/domain/test_grid.py
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:
cells = [[True, False, True], [False, True, False], [True, True, False],
[False, False, True], [True, False, False]]
# 5x3 is too small (width < 5), use 5x5
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)
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/domain/test_grid.py -v
```
Expected: `ImportError`.
- [ ] **Step 3: Implement `Grid`**
```python
# src/logimage/domain/value_objects/grid.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Grid:
cells: tuple[tuple[bool, ...], ...]
def __post_init__(self) -> None:
height = len(self.cells)
if height == 0:
raise ValueError("height must be between 5 and 50")
width = len(self.cells[0])
if not (5 <= width <= 50):
raise ValueError(f"width must be between 5 and 50, got {width}")
if not (5 <= height <= 50):
raise ValueError(f"height must be between 5 and 50, got {height}")
@property
def width(self) -> int:
return len(self.cells[0])
@property
def height(self) -> int:
return len(self.cells)
def row(self, index: int) -> tuple[bool, ...]:
return self.cells[index]
def col(self, index: int) -> tuple[bool, ...]:
return tuple(row[index] for row in self.cells)
@classmethod
def from_list(cls, cells: list[list[bool]]) -> "Grid":
return cls(cells=tuple(tuple(row) for row in cells))
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/domain/test_grid.py -v
```
Expected: 9 passed.
- [ ] **Step 5: Commit**
```bash
git add src/logimage/domain/value_objects/grid.py tests/domain/test_grid.py
git commit -m "feat(domain): add Grid value object"
```
---
## Task 4: Domain ports and `ImageData`
**Files:**
- Create: `src/logimage/domain/value_objects/image_data.py`
- Create: `src/logimage/domain/ports/image_source.py`
- Create: `src/logimage/domain/ports/image_converter.py`
- Create: `src/logimage/domain/ports/pdf_exporter.py`
No business logic here — verify they are importable.
- [ ] **Step 1: Create `ImageData`**
```python
# src/logimage/domain/value_objects/image_data.py
from dataclasses import dataclass
@dataclass(frozen=True)
class ImageData:
content: bytes
title: str
```
- [ ] **Step 2: Create `ImageSource` port**
```python
# src/logimage/domain/ports/image_source.py
from abc import ABC, abstractmethod
from logimage.domain.value_objects.image_data import ImageData
class ImageSource(ABC):
@abstractmethod
def fetch(self, theme: str | None = None) -> ImageData: ...
```
- [ ] **Step 3: Create `ImageConverter` port**
```python
# src/logimage/domain/ports/image_converter.py
from abc import ABC, abstractmethod
from logimage.domain.value_objects.grid import Grid
class ImageConverter(ABC):
@abstractmethod
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid: ...
```
- [ ] **Step 4: Create `PdfExporter` port**
```python
# src/logimage/domain/ports/pdf_exporter.py
from abc import ABC, abstractmethod
from pathlib import Path
from logimage.domain.entities.puzzle import NonogramPuzzle
class PdfExporter(ABC):
@abstractmethod
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None: ...
```
- [ ] **Step 5: Verify imports work**
```bash
python -c "
from logimage.domain.value_objects.image_data import ImageData
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.ports.image_converter import ImageConverter
print('OK')
"
```
Note: `PdfExporter` imports `NonogramPuzzle` which does not exist yet — skip its import check until Task 5.
Expected: `OK`
- [ ] **Step 6: Commit**
```bash
git add src/logimage/domain/value_objects/image_data.py src/logimage/domain/ports/
git commit -m "feat(domain): add ports and ImageData value object"
```
---
## Task 5: `NonogramPuzzle` entity
**Files:**
- Create: `src/logimage/domain/entities/puzzle.py`
- Create: `tests/domain/test_puzzle.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/domain/test_puzzle.py
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")
# col 0: T,F,T,F,T → 1,1,1
assert puzzle.col_clues[0] == Clue(values=(1, 1, 1))
# col 1: F,T,T,F,F → 2
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
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/domain/test_puzzle.py -v
```
Expected: `ImportError`.
- [ ] **Step 3: Implement `NonogramPuzzle`**
```python
# src/logimage/domain/entities/puzzle.py
from dataclasses import dataclass
from logimage.domain.value_objects.grid import Grid
from logimage.domain.value_objects.clue import Clue
@dataclass(frozen=True)
class NonogramPuzzle:
grid: Grid
row_clues: tuple[Clue, ...]
col_clues: tuple[Clue, ...]
title: str
@classmethod
def from_grid(cls, grid: Grid, title: str) -> "NonogramPuzzle":
row_clues = tuple(Clue.from_row(grid.row(i)) for i in range(grid.height))
col_clues = tuple(Clue.from_row(grid.col(j)) for j in range(grid.width))
return cls(grid=grid, row_clues=row_clues, col_clues=col_clues, title=title)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/domain/ -v
```
Expected: all domain tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/logimage/domain/entities/puzzle.py tests/domain/test_puzzle.py
git commit -m "feat(domain): add NonogramPuzzle entity"
```
---
## Task 6: Test fakes + `GeneratePuzzlesUseCase`
**Files:**
- Create: `tests/fakes/fakes.py`
- Create: `src/logimage/application/use_cases/generate_puzzles.py`
- Create: `tests/application/test_generate_puzzles.py`
- [ ] **Step 1: Create test fakes**
```python
# tests/fakes/fakes.py
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
```
- [ ] **Step 2: Write failing tests for the use case**
```python
# tests/application/test_generate_puzzles.py
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 = FakeImageConverter(), FakeImageConverter(), FakePdfExporter()
use_case = GeneratePuzzlesUseCase(source, FakeImageConverter(), exporter)
use_case.execute(GeneratePuzzlesRequest())
assert exporter.exported_puzzles[0].title == "Beautiful Cat"
```
- [ ] **Step 3: Run tests to verify they fail**
```bash
pytest tests/application/test_generate_puzzles.py -v
```
Expected: `ImportError`.
- [ ] **Step 4: Implement `GeneratePuzzlesUseCase`**
```python
# src/logimage/application/use_cases/generate_puzzles.py
from dataclasses import dataclass, field
from pathlib import Path
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.ports.pdf_exporter import PdfExporter
_DIFFICULTY_SIZES: dict[str, tuple[int, int]] = {
"easy": (10, 10),
"medium": (15, 15),
"hard": (20, 20),
}
@dataclass
class GeneratePuzzlesRequest:
count: int = 1
difficulty: str = "medium"
size: tuple[int, int] | None = None
theme: str | None = None
output_path: Path = field(default_factory=lambda: Path("puzzles.pdf"))
with_solution: bool = False
class GeneratePuzzlesUseCase:
def __init__(
self,
image_source: ImageSource,
image_converter: ImageConverter,
pdf_exporter: PdfExporter,
) -> None:
self._image_source = image_source
self._image_converter = image_converter
self._pdf_exporter = pdf_exporter
def execute(self, request: GeneratePuzzlesRequest) -> None:
width, height = request.size or _DIFFICULTY_SIZES.get(request.difficulty, (15, 15))
puzzles: list[NonogramPuzzle] = []
for _ in range(request.count):
image_data = self._image_source.fetch(request.theme)
grid = self._image_converter.to_grid(image_data.content, width, height)
puzzle = NonogramPuzzle.from_grid(grid, image_data.title)
puzzles.append(puzzle)
self._pdf_exporter.export(puzzles, request.output_path, request.with_solution)
```
- [ ] **Step 5: Run all tests**
```bash
pytest tests/domain/ tests/application/ -v
```
Expected: all pass.
- [ ] **Step 6: Commit**
```bash
git add tests/fakes/ src/logimage/application/ tests/application/
git commit -m "feat(application): add GeneratePuzzlesUseCase with fakes"
```
---
## Task 7: `PillowImageConverter`
**Files:**
- Create: `src/logimage/infrastructure/image/pillow_converter.py`
- Create: `tests/infrastructure/test_pillow_converter.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/infrastructure/test_pillow_converter.py
import io
import pytest
from PIL import Image
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.domain.value_objects.grid import Grid
def _make_jpeg_bytes(width: int = 100, height: int = 100) -> bytes:
img = Image.new("RGB", (width, height), color=(128, 64, 192))
buf = io.BytesIO()
img.save(buf, format="JPEG")
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
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/infrastructure/test_pillow_converter.py -v
```
Expected: `ImportError`.
- [ ] **Step 3: Implement `PillowImageConverter`**
```python
# src/logimage/infrastructure/image/pillow_converter.py
import io
import cv2
import numpy as np
from PIL import Image
from logimage.domain.ports.image_converter import ImageConverter
from logimage.domain.value_objects.grid import Grid
class PillowImageConverter(ImageConverter):
def to_grid(self, image_bytes: bytes, width: int, height: int) -> Grid:
img = Image.open(io.BytesIO(image_bytes)).convert("L")
img = img.resize((width, height), Image.LANCZOS)
arr = np.array(img, dtype=np.uint8)
edges = cv2.Canny(arr, 50, 150)
blended = cv2.addWeighted(arr, 0.6, edges, 0.4, 0)
_, binary = cv2.threshold(
blended, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
)
cells = [
[bool(binary[y, x]) for x in range(width)]
for y in range(height)
]
return Grid.from_list(cells)
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/infrastructure/test_pillow_converter.py -v
```
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add src/logimage/infrastructure/image/pillow_converter.py tests/infrastructure/test_pillow_converter.py
git commit -m "feat(infra): add PillowImageConverter with edge detection"
```
---
## Task 8: `UnsplashImageSource`
**Files:**
- Create: `src/logimage/infrastructure/image/unsplash_source.py`
- Create: `tests/infrastructure/test_unsplash_source.py`
- [ ] **Step 1: Write failing integration test**
```python
# tests/infrastructure/test_unsplash_source.py
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(monkeypatch: pytest.MonkeyPatch) -> 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
```
- [ ] **Step 2: Run test to verify it fails (or skips without key)**
```bash
pytest tests/infrastructure/test_unsplash_source.py -v -m integration
```
Expected: `ImportError` (module not yet created).
- [ ] **Step 3: Implement `UnsplashImageSource`**
```python
# src/logimage/infrastructure/image/unsplash_source.py
import httpx
from logimage.domain.ports.image_source import ImageSource
from logimage.domain.value_objects.image_data import ImageData
_API_URL = "https://api.unsplash.com/photos/random"
class UnsplashImageSource(ImageSource):
def __init__(self, api_key: str) -> None:
self._api_key = api_key
def fetch(self, theme: str | None = None) -> ImageData:
params: dict[str, str] = {}
if theme:
params["query"] = theme
with httpx.Client(timeout=30.0) as client:
response = client.get(
_API_URL,
headers={"Authorization": f"Client-ID {self._api_key}"},
params=params,
)
response.raise_for_status()
photo = response.json()
image_url = photo["urls"]["regular"]
title: str = (
photo.get("alt_description")
or photo.get("description")
or theme
or "logimage"
)
image_response = client.get(image_url, timeout=60.0)
image_response.raise_for_status()
return ImageData(content=image_response.content, title=title)
```
- [ ] **Step 4: Run integration tests (requires `UNSPLASH_ACCESS_KEY`)**
```bash
pytest tests/infrastructure/test_unsplash_source.py -v -m integration
```
Expected: 2 passed (or skipped if no API key set).
- [ ] **Step 5: Commit**
```bash
git add src/logimage/infrastructure/image/unsplash_source.py tests/infrastructure/test_unsplash_source.py
git commit -m "feat(infra): add UnsplashImageSource"
```
---
## Task 9: `ReportLabPdfExporter`
**Files:**
- Create: `src/logimage/infrastructure/pdf/reportlab_exporter.py`
- Create: `tests/infrastructure/test_reportlab_exporter.py`
- [ ] **Step 1: Write failing tests**
```python
# tests/infrastructure/test_reportlab_exporter.py
import tempfile
from pathlib import Path
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"
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest tests/infrastructure/test_reportlab_exporter.py -v
```
Expected: `ImportError`.
- [ ] **Step 3: Implement `ReportLabPdfExporter`**
```python
# src/logimage/infrastructure/pdf/reportlab_exporter.py
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen.canvas import Canvas
from logimage.domain.entities.puzzle import NonogramPuzzle
from logimage.domain.ports.pdf_exporter import PdfExporter
_PAGE_W, _PAGE_H = A4
_MARGIN = 20 * mm
_TITLE_H = 12 * mm
_CLUE_CELL_W = 6 * mm
_CLUE_CELL_H = 5 * mm
_CELL_SIZE_MAX = 14 * mm
_FONT_SIZE = 7
_GROUP_SIZE = 5
class ReportLabPdfExporter(PdfExporter):
def export(
self,
puzzles: list[NonogramPuzzle],
path: Path,
with_solution: bool = False,
) -> None:
c = Canvas(str(path), pagesize=A4)
for puzzle in puzzles:
_draw_page(c, puzzle, filled=False)
c.showPage()
if with_solution:
_draw_page(c, puzzle, filled=True)
c.showPage()
c.save()
def _draw_page(c: Canvas, puzzle: NonogramPuzzle, filled: bool) -> None:
grid = puzzle.grid
max_row_clues = max(len(cl.values) for cl in puzzle.row_clues)
max_col_clues = max(len(cl.values) for cl in puzzle.col_clues)
row_clue_w = max(max_row_clues * _CLUE_CELL_W, 10 * mm)
col_clue_h = max(max_col_clues * _CLUE_CELL_H, 5 * mm)
avail_w = _PAGE_W - 2 * _MARGIN - row_clue_w
avail_h = _PAGE_H - 2 * _MARGIN - _TITLE_H - col_clue_h
cell_size = min(avail_w / grid.width, avail_h / grid.height, _CELL_SIZE_MAX)
grid_w = cell_size * grid.width
grid_h = cell_size * grid.height
block_w = row_clue_w + grid_w
block_h = col_clue_h + grid_h
origin_x = (_PAGE_W - block_w) / 2
# Vertical center (below title)
origin_y = (_PAGE_H - _TITLE_H - block_h) / 2 + _TITLE_H / 2
grid_x = origin_x + row_clue_w
grid_y = origin_y # bottom of grid
# Title
label = f"{puzzle.title} — SOLUTION" if filled else puzzle.title
c.setFont("Helvetica-Bold", 13)
c.drawCentredString(
_PAGE_W / 2,
origin_y + block_h + _MARGIN / 2,
label,
)
# Grid cells
for row in range(grid.height):
for col in range(grid.width):
x = grid_x + col * cell_size
y = grid_y + (grid.height - 1 - row) * cell_size
if filled and grid.row(row)[col]:
c.setFillColorRGB(0, 0, 0)
c.rect(x, y, cell_size, cell_size, fill=1, stroke=0)
c.setFillColorRGB(0, 0, 0)
lw = 1.5 if col % _GROUP_SIZE == 0 or col == grid.width - 1 else 0.3
c.setLineWidth(lw)
c.rect(x, y, cell_size, cell_size, fill=0, stroke=1)
# Thicker horizontal line every 5 rows
row_lw = 1.5 if row % _GROUP_SIZE == 0 or row == grid.height - 1 else 0.3
c.setLineWidth(row_lw)
y_line = grid_y + (grid.height - row) * cell_size
c.line(grid_x, y_line, grid_x + grid_w, y_line)
# Row clues (left of grid)
c.setFont("Helvetica", _FONT_SIZE)
for row, clue in enumerate(puzzle.row_clues):
cell_mid_y = grid_y + (grid.height - row - 0.5) * cell_size - _FONT_SIZE * 0.35
text = " ".join(str(v) for v in clue.values) if clue.values else "0"
c.drawRightString(grid_x - 2, cell_mid_y, text)
# Column clues (above grid, bottom-aligned)
for col, clue in enumerate(puzzle.col_clues):
cell_mid_x = grid_x + (col + 0.5) * cell_size
values = list(clue.values) if clue.values else [0]
n = len(values)
for i, val in enumerate(values):
# bottom-align: last value (i = n-1) sits just above grid
distance_from_bottom = (n - 1 - i) * _CLUE_CELL_H
val_y = grid_y + grid_h + distance_from_bottom + _CLUE_CELL_H / 2 - _FONT_SIZE * 0.35
c.drawCentredString(cell_mid_x, val_y, str(val))
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
pytest tests/infrastructure/test_reportlab_exporter.py -v
```
Expected: 4 passed.
- [ ] **Step 5: Commit**
```bash
git add src/logimage/infrastructure/pdf/reportlab_exporter.py tests/infrastructure/test_reportlab_exporter.py
git commit -m "feat(infra): add ReportLabPdfExporter"
```
---
## Task 10: CLI + final wiring
**Files:**
- Create: `src/logimage/cli/main.py`
- [ ] **Step 1: Implement `main.py`**
```python
# src/logimage/cli/main.py
import argparse
import sys
from pathlib import Path
from dotenv import load_dotenv
import os
from logimage.application.use_cases.generate_puzzles import (
GeneratePuzzlesRequest,
GeneratePuzzlesUseCase,
)
from logimage.infrastructure.image.unsplash_source import UnsplashImageSource
from logimage.infrastructure.image.pillow_converter import PillowImageConverter
from logimage.infrastructure.pdf.reportlab_exporter import ReportLabPdfExporter
def _parse_size(value: str) -> tuple[int, int]:
parts = value.lower().split("x")
if len(parts) != 2:
raise argparse.ArgumentTypeError(f"Size must be WxH (e.g. 20x15), got '{value}'")
try:
w, h = int(parts[0]), int(parts[1])
except ValueError:
raise argparse.ArgumentTypeError(f"Width and height must be integers, got '{value}'")
return w, h
def main() -> None:
load_dotenv()
parser = argparse.ArgumentParser(
description="Generate nonogram (logimage) puzzles as a printable PDF."
)
parser.add_argument("--theme", help="Image search keyword (default: random)")
parser.add_argument(
"--difficulty",
choices=["easy", "medium", "hard"],
default="medium",
help="Grid size preset: easy=10×10, medium=15×15, hard=20×20 (default: medium)",
)
parser.add_argument(
"--size",
type=_parse_size,
metavar="WxH",
help="Custom grid size, e.g. 20x25 (overrides --difficulty)",
)
parser.add_argument("--count", type=int, default=1, help="Number of puzzles (default: 1)")
parser.add_argument(
"--solution",
action="store_true",
help="Append a solution page after each puzzle",
)
parser.add_argument(
"--output",
type=Path,
default=Path("puzzles.pdf"),
metavar="PATH",
help="Output PDF path (default: puzzles.pdf)",
)
args = parser.parse_args()
api_key = os.environ.get("UNSPLASH_ACCESS_KEY")
if not api_key:
print(
"Error: UNSPLASH_ACCESS_KEY is not set.\n"
"Create a .env file with UNSPLASH_ACCESS_KEY=<your_key> "
"or set the environment variable.",
file=sys.stderr,
)
sys.exit(1)
use_case = GeneratePuzzlesUseCase(
image_source=UnsplashImageSource(api_key=api_key),
image_converter=PillowImageConverter(),
pdf_exporter=ReportLabPdfExporter(),
)
request = GeneratePuzzlesRequest(
count=args.count,
difficulty=args.difficulty,
size=args.size,
theme=args.theme,
output_path=args.output,
with_solution=args.solution,
)
print(f"Generating {args.count} puzzle(s)…")
use_case.execute(request)
print(f"Done! PDF saved to: {args.output}")
```
- [ ] **Step 2: Smoke-test the CLI help**
```bash
python -m logimage --help
```
Expected: argument list printed, no errors.
- [ ] **Step 3: Run full test suite**
```bash
pytest tests/domain tests/application tests/infrastructure/test_pillow_converter.py tests/infrastructure/test_reportlab_exporter.py -v --cov=logimage --cov-report=term-missing
```
Expected: all pass, coverage ≥ 90% on `domain/` and `application/`.
- [ ] **Step 4: Commit**
```bash
git add src/logimage/cli/main.py
git commit -m "feat(cli): add CLI entry point and composition root"
```
---
## Task 11: End-to-end smoke test
This verifies the whole pipeline runs without a crash. Requires `UNSPLASH_ACCESS_KEY` in `.env`.
- [ ] **Step 1: Create `.env` from the example**
```bash
cp .env.example .env
# Edit .env and fill in your UNSPLASH_ACCESS_KEY
```
- [ ] **Step 2: Run end-to-end**
```bash
logimage --theme "architecture" --difficulty easy --count 2 --solution --output test_output.pdf
```
Expected:
```
Generating 2 puzzle(s)…
Done! PDF saved to: test_output.pdf
```
- [ ] **Step 3: Open `test_output.pdf` and verify**
- 4 pages total (2 puzzles × 2 pages each: puzzle + solution)
- Each puzzle page: title at top, grid centered, row clues on left, column clues above
- Each solution page: same grid with filled black cells
- [ ] **Step 4: Clean up test file and commit**
```bash
rm test_output.pdf
git add .env.example
git commit -m "chore: verify end-to-end pipeline works"
```