1471 lines
41 KiB
Markdown
1471 lines
41 KiB
Markdown
# 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"
|
||
```
|