import initial
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
# Image on Solution Page 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:** Afficher l'image originale à côté de la grille solution sur la page solution du PDF, dans une disposition deux colonnes égales en portrait.
|
||||
|
||||
**Architecture:** `NonogramPuzzle` reçoit un champ `image_bytes: bytes | None = None`. Le use case passe `image_data.content` lors de la construction du puzzle. Le PDF exporter détecte la présence d'image_bytes et divise la page solution en deux colonnes égales.
|
||||
|
||||
**Tech Stack:** Python, reportlab (drawImage, ImageReader), Pillow (pour générer des images dans les tests)
|
||||
|
||||
---
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
| Action | Fichier | Rôle |
|
||||
|----------|----------------------------------------------------------------------|-----------------------------------------------------|
|
||||
| Modifier | `src/logimage/domain/entities/puzzle.py` | Ajout champ `image_bytes` et paramètre `from_grid` |
|
||||
| Modifier | `src/logimage/application/use_cases/generate_puzzles.py` | Passe `image_data.content` à `from_grid` |
|
||||
| Modifier | `src/logimage/infrastructure/pdf/reportlab_exporter.py` | Layout deux colonnes sur page solution |
|
||||
| Modifier | `tests/domain/test_puzzle.py` | Tests du nouveau champ |
|
||||
| Modifier | `tests/application/test_generate_puzzles.py` | Test que les bytes sont transmis |
|
||||
| Modifier | `tests/infrastructure/test_reportlab_exporter.py` | Tests du rendu image |
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : NonogramPuzzle.image_bytes
|
||||
|
||||
**Fichiers :**
|
||||
- Modifier : `src/logimage/domain/entities/puzzle.py`
|
||||
- Modifier : `tests/domain/test_puzzle.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests failing**
|
||||
|
||||
Ajouter à la fin de `tests/domain/test_puzzle.py` :
|
||||
|
||||
```python
|
||||
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"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
pytest tests/domain/test_puzzle.py::test_puzzle_image_bytes_defaults_to_none tests/domain/test_puzzle.py::test_puzzle_image_bytes_stored -v
|
||||
```
|
||||
|
||||
Attendu : `TypeError: from_grid() takes 3 positional arguments but 4 were given` ou similar.
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Remplacer le contenu de `src/logimage/domain/entities/puzzle.py` par :
|
||||
|
||||
```python
|
||||
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
|
||||
image_bytes: bytes | None = None
|
||||
|
||||
@classmethod
|
||||
def from_grid(
|
||||
cls,
|
||||
grid: Grid,
|
||||
title: str,
|
||||
image_bytes: bytes | None = None,
|
||||
) -> "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,
|
||||
image_bytes=image_bytes,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/domain/test_puzzle.py -v
|
||||
```
|
||||
|
||||
Attendu : 8 tests PASSED.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/domain/entities/puzzle.py tests/domain/test_puzzle.py
|
||||
git commit -m "feat: add image_bytes field to NonogramPuzzle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Use case — transmission des bytes image
|
||||
|
||||
**Fichiers :**
|
||||
- Modifier : `src/logimage/application/use_cases/generate_puzzles.py`
|
||||
- Modifier : `tests/application/test_generate_puzzles.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test failing**
|
||||
|
||||
Ajouter à la fin de `tests/application/test_generate_puzzles.py` :
|
||||
|
||||
```python
|
||||
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"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que le test échoue**
|
||||
|
||||
```bash
|
||||
pytest tests/application/test_generate_puzzles.py::test_puzzle_image_bytes_comes_from_image_source -v
|
||||
```
|
||||
|
||||
Attendu : `AssertionError: assert None == b'real-image-bytes'`
|
||||
|
||||
- [ ] **Step 3 : Implémenter**
|
||||
|
||||
Dans `src/logimage/application/use_cases/generate_puzzles.py`, modifier la ligne qui construit le puzzle (actuellement ligne 41) :
|
||||
|
||||
```python
|
||||
# Avant :
|
||||
puzzle = NonogramPuzzle.from_grid(grid, image_data.title)
|
||||
|
||||
# Après :
|
||||
puzzle = NonogramPuzzle.from_grid(grid, image_data.title, image_data.content)
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Vérifier que le test passe**
|
||||
|
||||
```bash
|
||||
pytest tests/application/test_generate_puzzles.py -v
|
||||
```
|
||||
|
||||
Attendu : 12 tests PASSED.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/application/use_cases/generate_puzzles.py tests/application/test_generate_puzzles.py
|
||||
git commit -m "feat: pass image bytes to NonogramPuzzle in use case"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : PDF exporter — layout deux colonnes avec image
|
||||
|
||||
**Fichiers :**
|
||||
- Modifier : `src/logimage/infrastructure/pdf/reportlab_exporter.py`
|
||||
- Modifier : `tests/infrastructure/test_reportlab_exporter.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests failing**
|
||||
|
||||
Ajouter en haut de `tests/infrastructure/test_reportlab_exporter.py` (après les imports existants) :
|
||||
|
||||
```python
|
||||
import io
|
||||
from PIL import Image as PilImage
|
||||
```
|
||||
|
||||
Ajouter un helper et deux tests à la fin du fichier :
|
||||
|
||||
```python
|
||||
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"
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests passent déjà ou échouent selon le cas**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_reportlab_exporter.py::test_solution_with_image_bytes_creates_larger_file tests/infrastructure/test_reportlab_exporter.py::test_solution_without_image_bytes_still_produces_valid_pdf -v
|
||||
```
|
||||
|
||||
Attendu : `test_solution_without_image_bytes_still_produces_valid_pdf` PASS (comportement inchangé), `test_solution_with_image_bytes_creates_larger_file` FAIL (tailles égales, image non rendue).
|
||||
|
||||
- [ ] **Step 3 : Implémenter le layout deux colonnes dans le PDF exporter**
|
||||
|
||||
Remplacer le contenu complet de `src/logimage/infrastructure/pdf/reportlab_exporter.py` par :
|
||||
|
||||
```python
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
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
|
||||
_GUTTER = 10 * mm
|
||||
|
||||
|
||||
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)
|
||||
|
||||
has_image = filled and puzzle.image_bytes is not None
|
||||
|
||||
if has_image:
|
||||
avail_w = (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2 - row_clue_w
|
||||
else:
|
||||
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 = _MARGIN if has_image else (_PAGE_W - block_w) / 2
|
||||
origin_y = (_PAGE_H - _TITLE_H - block_h) / 2 + _TITLE_H / 2
|
||||
|
||||
grid_x = origin_x + row_clue_w
|
||||
grid_y = origin_y
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
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))
|
||||
|
||||
if has_image:
|
||||
img_x = _MARGIN + (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2 + _GUTTER
|
||||
img_w = (_PAGE_W - 2 * _MARGIN - _GUTTER) / 2
|
||||
reader = ImageReader(BytesIO(puzzle.image_bytes)) # type: ignore[arg-type]
|
||||
c.drawImage(reader, img_x, grid_y, width=img_w, height=grid_h, preserveAspectRatio=True, anchor="c")
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_reportlab_exporter.py -v
|
||||
```
|
||||
|
||||
Attendu : 6 tests PASSED.
|
||||
|
||||
- [ ] **Step 5 : Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v --ignore=tests/infrastructure/test_pexels_source.py --ignore=tests/infrastructure/test_unsplash_source.py
|
||||
```
|
||||
|
||||
Attendu : tous les tests passent.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/infrastructure/pdf/reportlab_exporter.py tests/infrastructure/test_reportlab_exporter.py
|
||||
git commit -m "feat: display original image next to solution grid on solution page"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
# Pexels Image Source 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:** Ajouter `PexelsImageSource` et router automatiquement vers Pexels ou Unsplash selon les clés d'API disponibles dans l'environnement.
|
||||
|
||||
**Architecture:** Le port `ImageSource` (ABC) existe déjà. On ajoute une implémentation `PexelsImageSource` dans l'infrastructure, puis on modifie `main.py` pour instancier le bon provider selon `PEXELS_API_KEY` (prioritaire) ou `UNSPLASH_ACCESS_KEY`.
|
||||
|
||||
**Tech Stack:** Python, httpx, pytest, pytest-mock
|
||||
|
||||
---
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
| Action | Fichier | Rôle |
|
||||
|----------|------------------------------------------------------------------|-------------------------------------|
|
||||
| Créer | `src/logimage/infrastructure/image/pexels_source.py` | Implémentation Pexels de ImageSource |
|
||||
| Créer | `tests/infrastructure/test_pexels_source.py` | Tests d'intégration Pexels |
|
||||
| Créer | `tests/cli/__init__.py` | Package tests CLI |
|
||||
| Créer | `tests/cli/test_main_routing.py` | Tests unitaires du routing CLI |
|
||||
| Modifier | `src/logimage/cli/main.py` | Routing selon les vars d'env |
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : PexelsImageSource
|
||||
|
||||
**Fichiers :**
|
||||
- Créer : `src/logimage/infrastructure/image/pexels_source.py`
|
||||
- Créer : `tests/infrastructure/test_pexels_source.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests d'intégration (failing)**
|
||||
|
||||
Créer `tests/infrastructure/test_pexels_source.py` :
|
||||
|
||||
```python
|
||||
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)
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_pexels_source.py -v -m integration
|
||||
```
|
||||
|
||||
Attendu : `ImportError: cannot import name 'PexelsImageSource'`
|
||||
|
||||
- [ ] **Step 3 : Implémenter PexelsImageSource**
|
||||
|
||||
Créer `src/logimage/infrastructure/image/pexels_source.py` :
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from logimage.domain.ports.image_source import ImageSource
|
||||
from logimage.domain.value_objects.image_data import ImageData
|
||||
|
||||
_SEARCH_URL = "https://api.pexels.com/v1/search"
|
||||
_CURATED_URL = "https://api.pexels.com/v1/curated"
|
||||
|
||||
|
||||
class PexelsImageSource(ImageSource):
|
||||
def __init__(self, api_key: str) -> None:
|
||||
self._api_key = api_key
|
||||
|
||||
def fetch(self, theme: str | None = None) -> ImageData:
|
||||
headers = {"Authorization": self._api_key}
|
||||
params: dict[str, str | int] = {"per_page": 1}
|
||||
|
||||
if theme:
|
||||
url = _SEARCH_URL
|
||||
params["query"] = theme
|
||||
else:
|
||||
url = _CURATED_URL
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
photos = response.json()["photos"]
|
||||
if not photos:
|
||||
raise ValueError(f"No photos found for theme={theme!r}")
|
||||
photo = photos[0]
|
||||
|
||||
image_url = photo["src"]["large"]
|
||||
title: str = photo.get("alt") 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 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_pexels_source.py -v -m integration
|
||||
```
|
||||
|
||||
Attendu : 2 tests PASSED (ou SKIPPED si `PEXELS_API_KEY` non définie)
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/infrastructure/image/pexels_source.py tests/infrastructure/test_pexels_source.py
|
||||
git commit -m "feat: add PexelsImageSource"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Routing CLI
|
||||
|
||||
**Fichiers :**
|
||||
- Modifier : `src/logimage/cli/main.py`
|
||||
- Créer : `tests/cli/__init__.py`
|
||||
- Créer : `tests/cli/test_main_routing.py`
|
||||
|
||||
- [ ] **Step 1 : Écrire les tests unitaires du routing (failing)**
|
||||
|
||||
Créer `tests/cli/__init__.py` (vide).
|
||||
|
||||
Créer `tests/cli/test_main_routing.py` :
|
||||
|
||||
```python
|
||||
import pytest
|
||||
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
|
||||
|
||||
|
||||
def test_pexels_key_uses_pexels_source(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) -> 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) -> 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_no_key_exits_with_error(monkeypatch, capsys) -> 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
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
pytest tests/cli/test_main_routing.py -v
|
||||
```
|
||||
|
||||
Attendu : tests échouent (la logique de routing n'est pas encore implémentée)
|
||||
|
||||
- [ ] **Step 3 : Modifier `main.py` pour le routing**
|
||||
|
||||
Remplacer dans `src/logimage/cli/main.py` le bloc d'import et de construction du `image_source` :
|
||||
|
||||
```python
|
||||
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.pexels_source import PexelsImageSource
|
||||
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()
|
||||
|
||||
pexels_key = os.environ.get("PEXELS_API_KEY")
|
||||
unsplash_key = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
if pexels_key:
|
||||
image_source = PexelsImageSource(api_key=pexels_key)
|
||||
elif unsplash_key:
|
||||
image_source = UnsplashImageSource(api_key=unsplash_key)
|
||||
else:
|
||||
print(
|
||||
"Error: no image API key found.\n"
|
||||
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
use_case = GeneratePuzzlesUseCase(
|
||||
image_source=image_source,
|
||||
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 4 : Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/cli/test_main_routing.py -v
|
||||
```
|
||||
|
||||
Attendu : 4 tests PASSED
|
||||
|
||||
- [ ] **Step 5 : Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest tests/ -v --ignore=tests/infrastructure/test_pexels_source.py --ignore=tests/infrastructure/test_unsplash_source.py
|
||||
```
|
||||
|
||||
Attendu : tous les tests passent (les tests d'intégration réseau sont exclus)
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/cli/main.py tests/cli/__init__.py tests/cli/test_main_routing.py
|
||||
git commit -m "feat: route image source based on available API key (Pexels first, Unsplash fallback)"
|
||||
```
|
||||
@@ -0,0 +1,583 @@
|
||||
# Pipeline de conversion amélioré — 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:** Remplacer le pipeline Canny+blend+Otsu par un pipeline bilateral+posterize+Otsu+cleanup morphologique qui garantit ≤ 6 blocs par ligne/colonne.
|
||||
|
||||
**Architecture:** Le `PillowImageConverter` reçoit des paramètres configurables (`max_clue_blocks`, `min_component_size`, `max_cleanup_iterations`) et applique une boucle de nettoyage morphologique jusqu'à ce que la complexité des clues soit acceptable. Une `LocalFileImageSource` permet de tester avec des images locales sans appel API.
|
||||
|
||||
**Tech Stack:** Python 3.12, OpenCV (`cv2`), NumPy, Pillow
|
||||
|
||||
---
|
||||
|
||||
## Fichiers
|
||||
|
||||
- Modifier: `src/logimage/infrastructure/image/pillow_converter.py`
|
||||
- Créer: `src/logimage/infrastructure/image/local_file_source.py`
|
||||
- Modifier: `src/logimage/cli/main.py`
|
||||
- Modifier: `tests/infrastructure/test_pillow_converter.py`
|
||||
- Créer: `tests/fixtures/simple_icon.png` (généré dans les tests)
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Nouveau pipeline `PillowImageConverter`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/logimage/infrastructure/image/pillow_converter.py`
|
||||
- Modify: `tests/infrastructure/test_pillow_converter.py`
|
||||
|
||||
- [ ] **Step 1: Écrire les tests qui couvrent le nouveau comportement**
|
||||
|
||||
Remplacer le contenu de `tests/infrastructure/test_pillow_converter.py` par :
|
||||
|
||||
```python
|
||||
import io
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
import pytest
|
||||
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, 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
|
||||
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier que les nouveaux tests échouent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_pillow_converter.py -v
|
||||
```
|
||||
|
||||
Attendu : `test_max_clue_blocks_respected_on_silhouette` et `test_custom_max_clue_blocks` FAIL (le pipeline actuel ne garantit pas le seuil).
|
||||
|
||||
- [ ] **Step 3: Implémenter le nouveau pipeline**
|
||||
|
||||
Remplacer le contenu de `src/logimage/infrastructure/image/pillow_converter.py` par :
|
||||
|
||||
```python
|
||||
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 __init__(
|
||||
self,
|
||||
max_clue_blocks: int = 6,
|
||||
min_component_size: int = 2,
|
||||
max_cleanup_iterations: int = 3,
|
||||
) -> None:
|
||||
self.max_clue_blocks = max_clue_blocks
|
||||
self.min_component_size = min_component_size
|
||||
self.max_cleanup_iterations = max_cleanup_iterations
|
||||
|
||||
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)
|
||||
|
||||
filtered = cv2.bilateralFilter(arr, d=9, sigmaColor=75, sigmaSpace=75)
|
||||
|
||||
median = int(np.median(filtered))
|
||||
posterized = np.where(filtered < median, np.uint8(0), np.uint8(255))
|
||||
|
||||
_, binary = cv2.threshold(
|
||||
posterized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU
|
||||
)
|
||||
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
||||
binary = self._remove_small_components(binary)
|
||||
|
||||
kernel_size = 3
|
||||
for _ in range(self.max_cleanup_iterations):
|
||||
if self._max_blocks(binary) <= self.max_clue_blocks:
|
||||
break
|
||||
kernel_size += 1
|
||||
k = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_size, kernel_size))
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, k)
|
||||
binary = self._remove_small_components(binary)
|
||||
|
||||
cells = [
|
||||
[bool(binary[y, x]) for x in range(width)]
|
||||
for y in range(height)
|
||||
]
|
||||
return Grid.from_list(cells)
|
||||
|
||||
def _remove_small_components(self, binary: np.ndarray) -> np.ndarray:
|
||||
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
|
||||
binary, connectivity=8
|
||||
)
|
||||
result = np.zeros_like(binary)
|
||||
for label in range(1, num_labels):
|
||||
if stats[label, cv2.CC_STAT_AREA] >= self.min_component_size:
|
||||
result[labels == label] = 255
|
||||
return result
|
||||
|
||||
def _max_blocks(self, binary: np.ndarray) -> int:
|
||||
max_b = 0
|
||||
for row in binary:
|
||||
max_b = max(max_b, self._count_blocks(row))
|
||||
for col in binary.T:
|
||||
max_b = max(max_b, self._count_blocks(col))
|
||||
return max_b
|
||||
|
||||
def _count_blocks(self, line: np.ndarray) -> int:
|
||||
blocks = 0
|
||||
in_block = False
|
||||
for pixel in line:
|
||||
if pixel > 0:
|
||||
if not in_block:
|
||||
blocks += 1
|
||||
in_block = True
|
||||
else:
|
||||
in_block = False
|
||||
return blocks
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier que tous les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_pillow_converter.py -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 5: Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/infrastructure/image/pillow_converter.py tests/infrastructure/test_pillow_converter.py
|
||||
git commit -m "feat: improve conversion pipeline with bilateral filter, posterize and morphological cleanup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : `LocalFileImageSource`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/logimage/infrastructure/image/local_file_source.py`
|
||||
- Create: `tests/infrastructure/test_local_file_source.py`
|
||||
|
||||
- [ ] **Step 1: Écrire le test**
|
||||
|
||||
Créer `tests/infrastructure/test_local_file_source.py` :
|
||||
|
||||
```python
|
||||
import io
|
||||
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()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier que les tests échouent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_local_file_source.py -v
|
||||
```
|
||||
|
||||
Attendu : FAIL (module inexistant).
|
||||
|
||||
- [ ] **Step 3: Implémenter `LocalFileImageSource`**
|
||||
|
||||
Créer `src/logimage/infrastructure/image/local_file_source.py` :
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from logimage.domain.ports.image_source import ImageSource
|
||||
from logimage.domain.value_objects.image_data import ImageData
|
||||
|
||||
|
||||
class LocalFileImageSource(ImageSource):
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._path = path
|
||||
|
||||
def fetch(self, theme: str | None = None) -> ImageData:
|
||||
if not self._path.exists():
|
||||
raise FileNotFoundError(f"Image not found: {self._path}")
|
||||
return ImageData(content=self._path.read_bytes(), title=self._path.stem)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_local_file_source.py -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 5: Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/infrastructure/image/local_file_source.py tests/infrastructure/test_local_file_source.py
|
||||
git commit -m "feat: add LocalFileImageSource for local image input"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Option `--local-image` dans le CLI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/logimage/cli/main.py`
|
||||
- Modify: `tests/cli/test_main_routing.py`
|
||||
|
||||
- [ ] **Step 1: Lire les tests CLI existants pour comprendre le pattern**
|
||||
|
||||
```bash
|
||||
cat -n tests/cli/test_main_routing.py
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Écrire le test pour `--local-image`**
|
||||
|
||||
Ajouter à `tests/cli/test_main_routing.py` (après les imports existants, ajouter l'import et les tests suivants) :
|
||||
|
||||
```python
|
||||
# Ajouter en haut du fichier si pas déjà présent :
|
||||
# from pathlib import Path
|
||||
# import io
|
||||
# from PIL import Image
|
||||
|
||||
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_local_image_flag_uses_local_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
icon = _make_png_file(tmp_path)
|
||||
output = tmp_path / "out.pdf"
|
||||
|
||||
captured_source = {}
|
||||
|
||||
original_init = GeneratePuzzlesUseCase.__init__
|
||||
|
||||
def patched_init(self, image_source, image_converter, pdf_exporter):
|
||||
captured_source["source"] = image_source
|
||||
original_init(self, image_source, image_converter, pdf_exporter)
|
||||
|
||||
monkeypatch.setattr(GeneratePuzzlesUseCase, "__init__", patched_init)
|
||||
monkeypatch.setattr("logimage.application.use_cases.generate_puzzles.GeneratePuzzlesUseCase.execute", lambda *a, **kw: None)
|
||||
|
||||
from logimage.cli.main import main
|
||||
monkeypatch.setattr("sys.argv", ["logimage", "--local-image", str(icon), "--output", str(output)])
|
||||
main()
|
||||
|
||||
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
|
||||
assert isinstance(captured_source["source"], LocalFileImageSource)
|
||||
```
|
||||
|
||||
Note: si le pattern de test existant est différent (mocks directs, fixtures spécifiques), adapte ce test au style du fichier.
|
||||
|
||||
- [ ] **Step 3: Vérifier que le test échoue**
|
||||
|
||||
```bash
|
||||
pytest tests/cli/test_main_routing.py -v -k "test_local_image"
|
||||
```
|
||||
|
||||
Attendu : FAIL.
|
||||
|
||||
- [ ] **Step 4: Ajouter l'option `--local-image` dans `main.py`**
|
||||
|
||||
Dans `src/logimage/cli/main.py`, après les imports existants ajouter :
|
||||
|
||||
```python
|
||||
from logimage.infrastructure.image.local_file_source import LocalFileImageSource
|
||||
```
|
||||
|
||||
Dans la fonction `main()`, après la définition de `--output`, ajouter l'argument :
|
||||
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--local-image",
|
||||
type=Path,
|
||||
metavar="PATH",
|
||||
help="Use a local image file instead of fetching from an API",
|
||||
)
|
||||
```
|
||||
|
||||
Remplacer le bloc qui crée `image_source` (lignes avec `pexels_key`, `unsplash_key`) par :
|
||||
|
||||
```python
|
||||
image_source: ImageSource
|
||||
if args.local_image:
|
||||
image_source = LocalFileImageSource(args.local_image)
|
||||
elif pexels_key:
|
||||
image_source = PexelsImageSource(api_key=pexels_key)
|
||||
elif unsplash_key:
|
||||
image_source = UnsplashImageSource(api_key=unsplash_key)
|
||||
else:
|
||||
print(
|
||||
"Error: no image API key found.\n"
|
||||
"Set PEXELS_API_KEY or UNSPLASH_ACCESS_KEY in your .env file.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/cli/ -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 6: Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/logimage/cli/main.py tests/cli/test_main_routing.py
|
||||
git commit -m "feat: add --local-image CLI option for local file input"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Test d'intégration avec image locale
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/infrastructure/test_pipeline_integration.py`
|
||||
|
||||
- [ ] **Step 1: Écrire le test d'intégration**
|
||||
|
||||
Créer `tests/infrastructure/test_pipeline_integration.py` :
|
||||
|
||||
```python
|
||||
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
|
||||
|
||||
|
||||
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 _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
|
||||
|
||||
|
||||
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 grid.width == 15
|
||||
assert grid.height == 15
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Vérifier que les tests passent**
|
||||
|
||||
```bash
|
||||
pytest tests/infrastructure/test_pipeline_integration.py -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 3: Lancer la suite complète**
|
||||
|
||||
```bash
|
||||
pytest -v
|
||||
```
|
||||
|
||||
Attendu : tous PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/infrastructure/test_pipeline_integration.py
|
||||
git commit -m "test: add integration tests for local image pipeline"
|
||||
```
|
||||
Reference in New Issue
Block a user