diff --git a/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py b/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py
index a0a2817..4df6d29 100644
--- a/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py
+++ b/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py
@@ -25,8 +25,14 @@ def import_recipes_from_uploaded_qr_cards(
invalid payload, are recorded as failures; processing continues with the
remaining files. Each failure is also published as an event so the
reason is visible in the dev terminal and the events log file.
+
+ A card whose settings match a recipe already in the library does not
+ create anything, so the successes are also split into the recipes the
+ import created and the ones it completed by naming them.
"""
imported: list[models.FujifilmRecipe] = []
+ created: list[models.FujifilmRecipe] = []
+ updated: list[models.FujifilmRecipe] = []
failed: list[str] = []
for file in files:
@@ -35,8 +41,14 @@ def import_recipes_from_uploaded_qr_cards(
with tempfile.NamedTemporaryFile(suffix=".jpg", dir="/tmp/", delete=False) as tmp:
tmp.write(file.content)
tmp_path = tmp.name
- recipe, _ = operations.get_or_create_recipe_from_qr_card(filepath=tmp_path)
+ recipe, outcome = operations.get_or_create_recipe_from_qr_card_and_backfill_name(
+ filepath=tmp_path
+ )
imported.append(recipe)
+ if outcome is recipe_dataclasses.RecipeImportOutcome.CREATED:
+ created.append(recipe)
+ elif outcome is recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED:
+ updated.append(recipe)
except QRCodeNotFoundError:
failed.append(file.name)
events.publish_event(
@@ -58,4 +70,6 @@ def import_recipes_from_uploaded_qr_cards(
return recipe_dataclasses.ImportRecipesResult(
imported=tuple(imported),
failed=tuple(failed),
+ created=tuple(created),
+ updated=tuple(updated),
)
diff --git a/src/domain/images/events.py b/src/domain/images/events.py
index 96cc555..62510e4 100644
--- a/src/domain/images/events.py
+++ b/src/domain/images/events.py
@@ -8,6 +8,7 @@
RECIPE_DEDUPLICATED = "recipe.deduplicated"
RECIPE_IMAGE_CREATED = "recipe.image.created"
RECIPE_IMAGE_UPDATED = "recipe.image.updated"
+RECIPE_NAME_UPDATED = "recipe.name.updated"
RECIPE_COVER_IMAGE_SET = "recipe.cover.image.set"
RECIPE_SENSORS_SET = "recipe.sensors.set"
RECIPE_CARD_CREATED = "recipe.card.created"
diff --git a/src/domain/recipes/cards/queries.py b/src/domain/recipes/cards/queries.py
index 217d1bd..fde5115 100644
--- a/src/domain/recipes/cards/queries.py
+++ b/src/domain/recipes/cards/queries.py
@@ -223,6 +223,9 @@ class InvalidQRRecipePayloadError(Exception):
- ``"unknown_fields"`` — payload contains keys outside the known schema.
- ``"type_mismatch"`` — a field's value has the wrong type, or a
required field is missing.
+ - ``"invalid_field_value"`` — a field's value has the right type but is
+ not a legal value (an over-long or non-ASCII name, or an unknown
+ sensor name).
"""
image_path: str = ""
@@ -379,7 +382,7 @@ def _signed_decimal_or_none(value: int | float | None) -> str | None:
def get_recipe_data_from_qr_recipe(
- *, qr_recipe: card_dataclasses.QRFujifilmRecipe,
+ *, qr_recipe: card_dataclasses.QRFujifilmRecipe, image_path: str,
) -> image_dataclasses.FujifilmRecipeData:
"""
Translate a decoded QRFujifilmRecipe into a FujifilmRecipeData.
@@ -397,31 +400,43 @@ def get_recipe_data_from_qr_recipe(
- grain_size when grain_roughness is "Off".
- dynamic_range/highlight/shadow when DRP is active.
- color for mono sims; mono color fields for colour sims.
+
+ :raises InvalidQRRecipePayloadError: If a field's value passes the payload
+ type checks but is rejected by the FujifilmRecipeData validators (an
+ over-long or non-ASCII name, or a sensor name this deployment does not
+ know).
"""
- return recipe_normalization.normalize_recipe_data(
- image_dataclasses.FujifilmRecipeData(
- name=qr_recipe.name or "",
- film_simulation=qr_recipe.film_simulation,
- grain_roughness=qr_recipe.grain_roughness,
- d_range_priority=qr_recipe.d_range_priority,
- white_balance=qr_recipe.white_balance,
- white_balance_red=qr_recipe.white_balance_red,
- white_balance_blue=qr_recipe.white_balance_blue,
- color_chrome_effect=qr_recipe.color_chrome_effect or "Off",
- color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off",
- sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0",
- high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0",
- clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0",
- dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "",
- grain_size=qr_recipe.grain_size,
- highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0",
- shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0",
- color=_signed_decimal_or_none(qr_recipe.color) or "0",
- monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0",
- monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0",
- # v=1 payloads omit ``sensors``; the dataclass leaves it None there.
- # v=2 payloads may include it; either way we settle on an empty
- # tuple when absent so the FujifilmRecipeData validator is happy.
- sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (),
+ try:
+ return recipe_normalization.normalize_recipe_data(
+ image_dataclasses.FujifilmRecipeData(
+ name=qr_recipe.name or "",
+ film_simulation=qr_recipe.film_simulation,
+ grain_roughness=qr_recipe.grain_roughness,
+ d_range_priority=qr_recipe.d_range_priority,
+ white_balance=qr_recipe.white_balance,
+ white_balance_red=qr_recipe.white_balance_red,
+ white_balance_blue=qr_recipe.white_balance_blue,
+ color_chrome_effect=qr_recipe.color_chrome_effect or "Off",
+ color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off",
+ sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0",
+ high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0",
+ clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0",
+ dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "",
+ grain_size=qr_recipe.grain_size,
+ highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0",
+ shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0",
+ color=_signed_decimal_or_none(qr_recipe.color) or "0",
+ monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0",
+ monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0",
+ # v=1 payloads omit ``sensors``; the dataclass leaves it None there.
+ # v=2 payloads may include it; either way we settle on an empty
+ # tuple when absent so the FujifilmRecipeData validator is happy.
+ sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (),
+ )
)
- )
+ except ValueError:
+ # The FujifilmRecipeData validators raise plain ValueErrors. Translating
+ # them here keeps every bad-payload failure on the same exception, so a
+ # single unimportable card is recorded as a failed file by the caller
+ # instead of aborting the whole upload.
+ raise InvalidQRRecipePayloadError(image_path=image_path, reason="invalid_field_value")
diff --git a/src/domain/recipes/dataclasses.py b/src/domain/recipes/dataclasses.py
index 262169a..58c737b 100644
--- a/src/domain/recipes/dataclasses.py
+++ b/src/domain/recipes/dataclasses.py
@@ -1,10 +1,26 @@
from __future__ import annotations
+import enum
+
import attrs
from src.data import models
+class RecipeImportOutcome(enum.Enum):
+ """
+ What an import did to the library for one incoming recipe.
+
+ A recipe arriving from a shared card is matched against the existing
+ library by its settings, so importing it does not necessarily create
+ anything.
+ """
+
+ CREATED = "created"
+ NAME_BACKFILLED = "name_backfilled" # matched an existing recipe that had no name
+ UNCHANGED = "unchanged" # matched an existing recipe; nothing to add
+
+
@attrs.frozen
class UploadedFile:
"""
@@ -17,5 +33,29 @@ class UploadedFile:
@attrs.frozen
class ImportRecipesResult:
+ """
+ The outcome of importing a batch of files, one entry per file.
+
+ Importing a file does not necessarily add a recipe: the recipe it carries
+ is matched against the library by its settings, and may already be there.
+ So a successfully imported file lands in ``imported``, and additionally in
+ ``created`` if it added a recipe to the library, or in ``updated`` if it
+ completed a recipe that was already there by giving it a name.
+
+ A file that matched an existing recipe and had nothing to add appears only
+ in ``imported``. Two files carrying the same recipe both appear, so these
+ are counts of files, not of distinct recipes.
+
+ Only the QR-card import can tell these apart. The import that reads
+ recipes out of image EXIF leaves ``created`` and ``updated`` empty.
+
+ :param imported: recipes read successfully, one per file.
+ :param failed: filenames that could not be read.
+ :param created: recipes that were new to the library.
+ :param updated: recipes that were already in the library and got named.
+ """
+
imported: tuple[models.FujifilmRecipe, ...]
- failed: tuple[str, ...] # original filenames that could not be processed
+ failed: tuple[str, ...]
+ created: tuple[models.FujifilmRecipe, ...] = ()
+ updated: tuple[models.FujifilmRecipe, ...] = ()
diff --git a/src/domain/recipes/operations.py b/src/domain/recipes/operations.py
index d98cbe5..8a4276d 100644
--- a/src/domain/recipes/operations.py
+++ b/src/domain/recipes/operations.py
@@ -12,6 +12,7 @@
from src.domain.images import queries as image_queries
from collections.abc import Iterable
+from src.domain.recipes import dataclasses as recipe_dataclasses
from src.domain.recipes import normalization as recipe_normalization
from src.domain.recipes import queries as recipe_queries
from src.domain.recipes import sensors as recipe_sensors
@@ -239,6 +240,9 @@ def get_or_create_recipe_from_data(
This is the single seam for ``FujifilmRecipe.get_or_create`` — shared by
every caller that has already produced a FujifilmRecipeData (from EXIF,
from a QR card, or any future source).
+
+ Callers importing a recipe shared from another library want a name that
+ survives the get path: they use ``get_or_create_recipe_and_backfill_name``.
"""
data = recipe_normalization.normalize_recipe_data(data)
recipe_validation.validate_recipe_data(data)
@@ -341,6 +345,41 @@ def get_or_create_recipe_from_data(
return recipe, created
+def get_or_create_recipe_and_backfill_name(
+ *,
+ data: image_dataclasses.FujifilmRecipeData,
+ group_id: int | None = None,
+) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]:
+ """
+ Get or create the recipe for *data*, naming it if it has no name.
+
+ For recipes shared from another library, where the settings already match
+ an existing recipe but the local copy may predate that recipe being named.
+
+ Backfill only: an existing recipe is written to under exactly one
+ condition, that its ``name`` is empty and *data* carries one. A name
+ chosen locally is never overwritten, and ``description`` is never touched
+ (a QR card does not carry one).
+
+ local "" + incoming "Kodachrome" -> "Kodachrome" (backfilled)
+ local "My CC" + incoming "Kodachrome" -> "My CC" (kept)
+ local "My CC" + incoming "" -> "My CC" (kept)
+
+ Returns the recipe and what the import did to the library, so a caller
+ importing a batch can report how many recipes it created versus updated.
+ """
+ recipe, created = get_or_create_recipe_from_data(data=data, group_id=group_id)
+ if created:
+ return recipe, recipe_dataclasses.RecipeImportOutcome.CREATED
+ if data.name and not recipe.name:
+ # No RecipeNameValidationError handling: the name is empty (excluded
+ # by the guard) or it came through FujifilmRecipeData, whose validator
+ # enforces the same rules set_recipe_name checks.
+ set_recipe_name(recipe=recipe, name=data.name)
+ return recipe, recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED
+ return recipe, recipe_dataclasses.RecipeImportOutcome.UNCHANGED
+
+
def get_or_create_recipe_from_metadata(
*, metadata: image_dataclasses.ImageExifData,
) -> tuple[models.FujifilmRecipe, bool]:
@@ -370,19 +409,25 @@ def get_or_create_recipe_from_filepath(
return get_or_create_recipe_from_metadata(metadata=metadata)
-def get_or_create_recipe_from_qr_card(
+def get_or_create_recipe_from_qr_card_and_backfill_name(
*, filepath: str,
-) -> tuple[models.FujifilmRecipe, bool]:
+) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]:
"""
Decode the QR on a recipe-card image and return the matching FujifilmRecipe.
+ The card names the recipe it matches when that recipe has no name of its
+ own — see ``get_or_create_recipe_and_backfill_name``, which also explains
+ what the returned outcome means.
+
:raises QRCodeNotFoundError: If no QR code can be decoded from *filepath*.
:raises InvalidQRRecipePayloadError: If the decoded content is not a valid
QRFujifilmRecipe payload.
"""
qr_recipe = card_queries.get_qr_recipe_from_image(image_path=filepath)
- recipe_data = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr_recipe)
- return get_or_create_recipe_from_data(data=recipe_data)
+ recipe_data = card_queries.get_recipe_data_from_qr_recipe(
+ qr_recipe=qr_recipe, image_path=filepath,
+ )
+ return get_or_create_recipe_and_backfill_name(data=recipe_data)
@attrs.frozen
@@ -464,7 +509,7 @@ def set_recipe_name(*, recipe: models.FujifilmRecipe, name: str) -> None:
raise RecipeNameValidationError(name)
recipe.set_name(name=name)
events.publish_event(
- event_type=events.RECIPE_IMAGE_UPDATED,
+ event_type=events.RECIPE_NAME_UPDATED,
name=name,
recipe_id=recipe.pk,
)
diff --git a/src/interfaces/recipes/views.py b/src/interfaces/recipes/views.py
index 1350b69..35054d4 100644
--- a/src/interfaces/recipes/views.py
+++ b/src/interfaces/recipes/views.py
@@ -686,7 +686,12 @@ def post(self, request: http.HttpRequest) -> http.HttpResponse:
return shortcuts.render(
request,
"recipes/partials/_import_result.html",
- {"imported": result.imported, "failed": result.failed},
+ {
+ "imported": result.imported,
+ "failed": result.failed,
+ "created_count": len(result.created),
+ "updated_count": len(result.updated),
+ },
)
diff --git a/src/interfaces/templates/recipes/partials/_import_result.html b/src/interfaces/templates/recipes/partials/_import_result.html
index eafea51..6e0c781 100644
--- a/src/interfaces/templates/recipes/partials/_import_result.html
+++ b/src/interfaces/templates/recipes/partials/_import_result.html
@@ -11,6 +11,13 @@
{% endif %}
+ {% if created_count or updated_count %}
+
+ {{ created_count }} recipe{{ created_count|pluralize }} created,
+ {{ updated_count }} existing recipe{{ updated_count|pluralize }} updated.
+
+ {% endif %}
+
{% if failed %}
{{ failed|length }} file{{ failed|length|pluralize }} could not be processed:
diff --git a/tests/functional/test_import_recipes_from_qr_cards_view.py b/tests/functional/test_import_recipes_from_qr_cards_view.py
index 75a76b8..1be0480 100644
--- a/tests/functional/test_import_recipes_from_qr_cards_view.py
+++ b/tests/functional/test_import_recipes_from_qr_cards_view.py
@@ -1,8 +1,10 @@
+import json
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
import pytest
+import qrcode # type: ignore[import-untyped]
from bs4 import BeautifulSoup
from src.data import models
@@ -24,6 +26,39 @@ def _post(client, *filenames: str):
return client.post("/recipes/import-qr-cards/", data, format="multipart")
+def _card_upload(payload: dict[str, object], *, filename: str = "card.png") -> BytesIO:
+ """Render *payload* as a QR card image the view can accept as an upload."""
+ buffer = BytesIO()
+ qrcode.make(json.dumps(payload), box_size=10).save(buffer, format="PNG")
+ buffer.seek(0)
+ buffer.name = filename
+ return buffer
+
+
+def _post_card(client, payload: dict[str, object], *, filename: str = "card.png"):
+ return client.post(
+ "/recipes/import-qr-cards/",
+ {"images": _card_upload(payload, filename=filename)},
+ format="multipart",
+ )
+
+
+def _text(response) -> str:
+ """The response's visible text, with the template's line breaks collapsed."""
+ return " ".join(BeautifulSoup(response.content, "html.parser").get_text().split())
+
+
+PROVIA_PAYLOAD: dict[str, object] = {
+ "v": 1,
+ "film_simulation": "Provia",
+ "grain_roughness": "Off",
+ "d_range_priority": "Off",
+ "white_balance": "Auto",
+ "white_balance_red": 0,
+ "white_balance_blue": 0,
+}
+
+
@pytest.mark.django_db
class TestRecipesExplorerImportCardsOption:
def test_import_cards_option_is_present(self, client):
@@ -84,6 +119,38 @@ def test_deduplicates_same_card(self, client):
assert models.FujifilmRecipe.objects.count() == 1
+@pytest.mark.django_db
+class TestImportRecipesFromQRCardsViewOutcomeBreakdown:
+ """
+ A bulk import of shared cards mostly matches recipes the library already
+ has, so the result says how many it created and how many it completed.
+ """
+
+ def test_response_reports_a_new_recipe_as_created(self, client):
+ response = _post(client, "card_classic_chrome.jpg")
+
+ assert "1 recipe created, 0 existing recipes updated" in _text(response)
+
+ def test_response_reports_a_card_that_names_an_existing_recipe_as_updated(self, client):
+ _post_card(client, PROVIA_PAYLOAD, filename="nameless.png")
+
+ response = _post_card(
+ client, {**PROVIA_PAYLOAD, "name": "Kodachrome"}, filename="named.png"
+ )
+
+ assert "0 recipes created, 1 existing recipe updated" in _text(response)
+ assert models.FujifilmRecipe.objects.count() == 1
+ assert models.FujifilmRecipe.objects.get().name == "Kodachrome"
+
+ def test_response_omits_the_breakdown_when_nothing_changed(self, client):
+ _post(client, "card_classic_chrome.jpg")
+
+ response = _post(client, "card_classic_chrome.jpg")
+
+ assert "created," not in _text(response)
+ assert "1 recipe imported successfully" in _text(response)
+
+
@pytest.mark.django_db
class TestImportRecipesFromQRCardsViewFailure:
def test_no_files_returns_error_message(self, client):
diff --git a/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py b/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py
index 8a173d4..2d126b7 100644
--- a/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py
+++ b/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py
@@ -161,3 +161,87 @@ def test_empty_file_list_returns_empty_result(self) -> None:
result = import_recipes_from_uploaded_qr_cards(files=[])
assert result == ImportRecipesResult(imported=(), failed=())
+
+
+def _payload(**overrides: object) -> dict[str, object]:
+ payload: dict[str, object] = {
+ "v": 1,
+ "film_simulation": "Provia",
+ "grain_roughness": "Off",
+ "d_range_priority": "Off",
+ "white_balance": "Auto",
+ "white_balance_red": 0,
+ "white_balance_blue": 0,
+ }
+ payload.update(overrides)
+ return payload
+
+
+@pytest.mark.django_db
+class TestImportRecipesFromUploadedQRCardsOutcomes:
+ """
+ Importing a card does not necessarily create a recipe: its settings may
+ already match one. The result says which happened, so a bulk import can
+ report what it did to the library.
+ """
+
+ def test_reports_a_new_recipe_as_created(self, tmp_path: Path) -> None:
+ card = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="card.png")
+
+ result = import_recipes_from_uploaded_qr_cards(files=[card])
+
+ assert result.created == result.imported
+ assert result.updated == ()
+
+ def test_reports_a_card_that_names_an_existing_recipe_as_updated(self, tmp_path: Path) -> None:
+ nameless = _qr_file(tmp_path, json.dumps(_payload()), filename="nameless.png")
+ import_recipes_from_uploaded_qr_cards(files=[nameless])
+
+ named = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="named.png")
+ result = import_recipes_from_uploaded_qr_cards(files=[named])
+
+ assert result.created == ()
+ assert len(result.updated) == 1
+ assert result.updated[0].name == "Kodachrome"
+ assert models.FujifilmRecipe.objects.count() == 1
+
+ def test_reports_a_card_matching_a_named_recipe_as_neither(self, tmp_path: Path) -> None:
+ card = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="card.png")
+ import_recipes_from_uploaded_qr_cards(files=[card])
+
+ result = import_recipes_from_uploaded_qr_cards(files=[card])
+
+ assert len(result.imported) == 1
+ assert result.created == ()
+ assert result.updated == ()
+
+ def test_a_card_with_an_illegal_value_fails_alone(self, tmp_path: Path) -> None:
+ # A card exported by a library that knows a sensor this one doesn't.
+ # It must not take the rest of the batch down with it.
+ unknown_sensor = _qr_file(
+ tmp_path,
+ json.dumps(_payload(v=2, sensors=["X-Trans VI"])),
+ filename="future.png",
+ )
+ good = uploaded_file_from_fixture("card_classic_chrome.jpg")
+
+ result = import_recipes_from_uploaded_qr_cards(files=[unknown_sensor, good])
+
+ assert result.failed == ("future.png",)
+ assert len(result.imported) == 1
+ assert result.imported[0].film_simulation == "Classic Chrome"
+
+ def test_publishes_the_invalid_value_reason_for_a_card_that_fails_alone(
+ self, tmp_path: Path, captured_logs
+ ) -> None:
+ too_long = _qr_file(
+ tmp_path, json.dumps(_payload(name="x" * 26)), filename="long_name.png"
+ )
+
+ import_recipes_from_uploaded_qr_cards(files=[too_long])
+
+ failure_events = [
+ e for e in captured_logs if e.get("event_type") == events.RECIPE_IMPORT_QR_CARD_FAILED
+ ]
+ assert len(failure_events) == 1
+ assert failure_events[0]["failure_reason"] == "invalid_field_value"
diff --git a/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py b/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py
new file mode 100644
index 0000000..e1314ef
--- /dev/null
+++ b/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py
@@ -0,0 +1,149 @@
+import pytest
+
+from src.data import models
+from src.domain.images import dataclasses as image_dataclasses
+from src.domain.images import events
+from src.domain.recipes.dataclasses import RecipeImportOutcome
+from src.domain.recipes.operations import (
+ get_or_create_recipe_and_backfill_name,
+ get_or_create_recipe_from_data,
+)
+
+
+def _make_data(**overrides: object) -> image_dataclasses.FujifilmRecipeData:
+ base = dict(
+ film_simulation="Provia",
+ d_range_priority="Off",
+ grain_roughness="Off",
+ color_chrome_effect="Off",
+ color_chrome_fx_blue="Off",
+ white_balance="Auto",
+ white_balance_red=0,
+ white_balance_blue=0,
+ sharpness="0",
+ high_iso_nr="0",
+ clarity="0",
+ dynamic_range="DR100",
+ highlight="0",
+ shadow="0",
+ color="0",
+ )
+ base.update(overrides)
+ return image_dataclasses.FujifilmRecipeData(**base) # type: ignore[arg-type] # test helper merges typed defaults with arbitrary overrides via dict
+
+
+@pytest.mark.django_db
+class TestGetOrCreateRecipeAndBackfillName:
+ def test_creates_the_recipe_when_the_library_has_no_match(self) -> None:
+ recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome"))
+
+ assert recipe.name == "Kodachrome"
+ assert outcome is RecipeImportOutcome.CREATED
+
+ def test_backfills_the_name_of_a_matching_recipe_that_has_none(self) -> None:
+ existing, _ = get_or_create_recipe_from_data(data=_make_data(name=""))
+
+ recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome"))
+
+ assert recipe.pk == existing.pk
+ assert outcome is RecipeImportOutcome.NAME_BACKFILLED
+ existing.refresh_from_db()
+ assert existing.name == "Kodachrome"
+ assert models.FujifilmRecipe.objects.count() == 1
+
+ def test_keeps_a_name_already_chosen_locally(self) -> None:
+ existing, _ = get_or_create_recipe_from_data(data=_make_data(name="My Chrome"))
+
+ recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome"))
+
+ assert recipe.pk == existing.pk
+ assert outcome is RecipeImportOutcome.UNCHANGED
+ existing.refresh_from_db()
+ assert existing.name == "My Chrome"
+
+ def test_leaves_an_unnamed_recipe_unnamed_when_the_incoming_data_has_no_name(self) -> None:
+ existing, _ = get_or_create_recipe_from_data(data=_make_data(name=""))
+
+ recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name=""))
+
+ assert recipe.pk == existing.pk
+ assert outcome is RecipeImportOutcome.UNCHANGED
+ assert recipe.name == ""
+
+ def test_never_writes_the_description(self) -> None:
+ existing, _ = get_or_create_recipe_from_data(data=_make_data(name="", description=""))
+
+ get_or_create_recipe_and_backfill_name(
+ data=_make_data(name="Kodachrome", description="Notes from the other library")
+ )
+
+ existing.refresh_from_db()
+ assert existing.description == ""
+
+ def test_publishes_the_name_updated_event_when_it_backfills(
+ self, captured_logs: list[dict[str, object]]
+ ) -> None:
+ existing, _ = get_or_create_recipe_from_data(data=_make_data(name=""))
+ captured_logs.clear()
+
+ get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome"))
+
+ name_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED]
+ assert len(name_events) == 1
+ assert name_events[0]["recipe_id"] == existing.pk
+ assert name_events[0]["name"] == "Kodachrome"
+
+ def test_publishes_no_name_updated_event_when_nothing_is_backfilled(
+ self, captured_logs: list[dict[str, object]]
+ ) -> None:
+ get_or_create_recipe_from_data(data=_make_data(name="My Chrome"))
+ captured_logs.clear()
+
+ get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome"))
+
+ assert not [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED]
+
+
+@pytest.mark.django_db
+class TestGetOrCreateRecipeAndBackfillNameWithSensors:
+ """
+ The case this exists for: recipes shared from a newer library into an
+ older one, where the local copies predate both sensor tracking and being
+ named. Each card must find its local recipe and complete it, not duplicate it.
+ """
+
+ def test_backfills_name_and_sensors_of_a_sensorless_local_recipe(self) -> None:
+ local, _ = get_or_create_recipe_from_data(
+ data=_make_data(name="", sensors=(), white_balance_red=9001)
+ )
+ assert local.sensor_signature == ""
+
+ recipe, outcome = get_or_create_recipe_and_backfill_name(
+ data=_make_data(name="Kodachrome", sensors=("X-Trans V",), white_balance_red=9001)
+ )
+
+ assert recipe.pk == local.pk
+ assert outcome is RecipeImportOutcome.NAME_BACKFILLED
+ assert models.FujifilmRecipe.objects.count() == 1
+ local.refresh_from_db()
+ assert local.name == "Kodachrome"
+ assert [s.name for s in local.sensors.all()] == ["X-Trans V"]
+ assert local.sensor_signature == "x-trans v"
+
+ def test_creates_a_separate_recipe_for_a_different_sensor_set(self) -> None:
+ # Two cards with identical settings but different sensors are two
+ # recipes. The first claims the sensorless local recipe; the second
+ # can no longer match it and is created.
+ get_or_create_recipe_from_data(data=_make_data(name="", sensors=(), white_balance_red=9002))
+
+ first, first_outcome = get_or_create_recipe_and_backfill_name(
+ data=_make_data(name="Chrome IV", sensors=("X-Trans IV",), white_balance_red=9002)
+ )
+ second, second_outcome = get_or_create_recipe_and_backfill_name(
+ data=_make_data(name="Chrome V", sensors=("X-Trans V",), white_balance_red=9002)
+ )
+
+ assert first_outcome is RecipeImportOutcome.NAME_BACKFILLED
+ assert second_outcome is RecipeImportOutcome.CREATED
+ assert first.pk != second.pk
+ assert models.FujifilmRecipe.objects.count() == 2
diff --git a/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py b/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py
similarity index 71%
rename from tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py
rename to tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py
index 2fc6460..50a589b 100644
--- a/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py
+++ b/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py
@@ -8,6 +8,7 @@
from src.domain.images import events
from src.domain.recipes import operations as recipe_operations
from src.domain.recipes.cards import queries as card_queries
+from src.domain.recipes.dataclasses import RecipeImportOutcome
FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "fixtures" / "recipe_cards"
CLASSIC_CHROME_CARD = str(FIXTURES_DIR / "card_classic_chrome.jpg")
@@ -25,9 +26,9 @@ def _write_qr(tmp_path: Path, payload_str: str) -> str:
@pytest.mark.django_db
-class TestGetOrCreateRecipeFromQRCard:
+class TestGetOrCreateRecipeFromQRCardAndBackfillName:
def test_creates_recipe_from_colour_card_fixture(self) -> None:
- recipe, created = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD)
+ recipe, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD)
assert isinstance(recipe, models.FujifilmRecipe)
assert recipe.pk is not None
@@ -36,10 +37,10 @@ def test_creates_recipe_from_colour_card_fixture(self) -> None:
assert recipe.white_balance_red == 2
assert recipe.white_balance_blue == -1
assert recipe.color_chrome_effect == "Strong"
- assert created is True
+ assert outcome is RecipeImportOutcome.CREATED
def test_creates_recipe_from_monochromatic_card_fixture(self) -> None:
- recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=ACROS_CARD)
+ recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=ACROS_CARD)
assert recipe.film_simulation == "Acros STD"
assert recipe.grain_roughness == "Off"
@@ -50,7 +51,7 @@ def test_creates_recipe_from_monochromatic_card_fixture(self) -> None:
assert float(recipe.monochromatic_color_warm_cool) == -2.0
def test_publishes_recipe_created_event_on_first_import(self, captured_logs) -> None:
- recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD)
+ recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD)
created_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_CREATED]
assert len(created_events) == 1
@@ -58,13 +59,13 @@ def test_publishes_recipe_created_event_on_first_import(self, captured_logs) ->
assert created_events[0]["film_simulation"] == "Classic Chrome"
def test_returns_existing_recipe_on_reimport(self, captured_logs) -> None:
- first, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD)
+ first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD)
captured_logs.clear()
- second, created = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD)
+ second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD)
assert second.pk == first.pk
- assert created is False
+ assert outcome is not RecipeImportOutcome.CREATED
assert models.FujifilmRecipe.objects.count() == 1
created_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_CREATED]
assert created_events == []
@@ -82,7 +83,7 @@ def test_saves_name_from_payload_on_first_create(self, tmp_path: Path) -> None:
}
card = _write_qr(tmp_path, json.dumps(payload))
- recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=card)
+ recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=card)
assert recipe.name == "Shared Recipe"
@@ -97,19 +98,44 @@ def test_does_not_overwrite_name_on_dedup(self, tmp_path: Path) -> None:
"white_balance_red": 4,
"white_balance_blue": 4,
}
- first, _ = recipe_operations.get_or_create_recipe_from_qr_card(
+ first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
filepath=_write_qr(tmp_path, json.dumps(base_payload)),
)
- second, created = recipe_operations.get_or_create_recipe_from_qr_card(
+ second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
filepath=_write_qr(tmp_path, json.dumps({**base_payload, "name": "Different Name"})),
)
assert second.pk == first.pk
- assert created is False
+ assert outcome is RecipeImportOutcome.UNCHANGED
second.refresh_from_db()
assert second.name == "First Name"
+ def test_names_a_matching_recipe_that_has_no_name(self, tmp_path: Path) -> None:
+ payload = {
+ "v": 1,
+ "film_simulation": "Provia",
+ "grain_roughness": "Off",
+ "d_range_priority": "Off",
+ "white_balance": "Auto",
+ "white_balance_red": 6,
+ "white_balance_blue": 6,
+ }
+ nameless_card = _write_qr(tmp_path, json.dumps(payload))
+ first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
+ filepath=nameless_card,
+ )
+ assert first.name == ""
+
+ second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
+ filepath=_write_qr(tmp_path, json.dumps({**payload, "name": "Shared Name"})),
+ )
+
+ assert second.pk == first.pk
+ assert outcome is RecipeImportOutcome.NAME_BACKFILLED
+ first.refresh_from_db()
+ assert first.name == "Shared Name"
+
def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path) -> None:
payload = {
"v": 1,
@@ -121,12 +147,12 @@ def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path)
"white_balance_red": 5,
"white_balance_blue": 5,
}
- first, _ = recipe_operations.get_or_create_recipe_from_qr_card(
+ first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
filepath=_write_qr(tmp_path, json.dumps(payload)),
)
nameless_payload = {k: v for k, v in payload.items() if k != "name"}
- second, _ = recipe_operations.get_or_create_recipe_from_qr_card(
+ second, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(
filepath=_write_qr(tmp_path, json.dumps(nameless_payload)),
)
@@ -136,11 +162,11 @@ def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path)
def test_raises_qr_not_found_for_image_without_qr(self) -> None:
with pytest.raises(card_queries.QRCodeNotFoundError):
- recipe_operations.get_or_create_recipe_from_qr_card(filepath=NON_CARD_IMAGE)
+ recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=NON_CARD_IMAGE)
def test_raises_invalid_payload_for_bad_qr_content(self, tmp_path: Path) -> None:
# A QR that decodes but doesn't carry a valid recipe payload.
bad_qr = _write_qr(tmp_path, json.dumps({"v": 1, "wrong_key": "wrong"}))
with pytest.raises(card_queries.InvalidQRRecipePayloadError):
- recipe_operations.get_or_create_recipe_from_qr_card(filepath=bad_qr)
+ recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=bad_qr)
diff --git a/tests/integration/domain/recipes/test_set_recipe_name.py b/tests/integration/domain/recipes/test_set_recipe_name.py
index 568e770..da0c726 100644
--- a/tests/integration/domain/recipes/test_set_recipe_name.py
+++ b/tests/integration/domain/recipes/test_set_recipe_name.py
@@ -19,11 +19,11 @@ def test_only_updates_name_field(self):
recipe.refresh_from_db()
assert recipe.film_simulation == "Provia"
- def test_publishes_recipe_image_updated_event(self, captured_logs):
+ def test_publishes_recipe_name_updated_event(self, captured_logs):
recipe = FujifilmRecipeFactory(name="")
set_recipe_name(recipe=recipe, name="My Recipe")
- updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED]
+ updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED]
assert len(updated_events) == 1
assert updated_events[0]["name"] == "My Recipe"
assert updated_events[0]["recipe_id"] == recipe.pk
@@ -32,5 +32,5 @@ def test_event_params_contain_name_and_recipe_id(self, captured_logs):
recipe = FujifilmRecipeFactory(name="")
set_recipe_name(recipe=recipe, name="Velvia Vivid")
- updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED]
+ updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED]
assert {"name", "recipe_id"} <= updated_events[0].keys()
diff --git a/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py b/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py
index c19d047..a3fca1e 100644
--- a/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py
+++ b/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py
@@ -1,3 +1,6 @@
+import pytest
+
+from src.domain.images import dataclasses as image_dataclasses
from src.domain.recipes.cards import dataclasses as card_dataclasses
from src.domain.recipes.cards import queries as card_queries
@@ -28,7 +31,7 @@ def test_passes_through_required_string_and_int_fields(self) -> None:
white_balance_blue=-1,
)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.film_simulation == "Classic Chrome"
assert result.grain_roughness == "Weak"
@@ -40,7 +43,7 @@ def test_passes_through_required_string_and_int_fields(self) -> None:
def test_formats_decimal_zero_as_unsigned_string(self) -> None:
qr = _valid_qr(highlight=0, shadow=0, color=0, sharpness=0, high_iso_nr=0, clarity=0)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.highlight == "0"
assert result.shadow == "0"
@@ -52,7 +55,7 @@ def test_formats_decimal_zero_as_unsigned_string(self) -> None:
def test_formats_positive_decimal_with_plus_sign(self) -> None:
qr = _valid_qr(highlight=2, sharpness=1)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.highlight == "+2"
assert result.sharpness == "+1"
@@ -60,7 +63,7 @@ def test_formats_positive_decimal_with_plus_sign(self) -> None:
def test_formats_negative_decimal_without_extra_plus(self) -> None:
qr = _valid_qr(shadow=-1, high_iso_nr=-4)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.shadow == "-1"
assert result.high_iso_nr == "-4"
@@ -68,7 +71,7 @@ def test_formats_negative_decimal_without_extra_plus(self) -> None:
def test_formats_half_step_tone_decimals_as_signed_floats(self) -> None:
qr = _valid_qr(highlight=1.5, shadow=-1.5)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.highlight == "+1.5"
assert result.shadow == "-1.5"
@@ -80,7 +83,7 @@ def test_formats_half_step_mono_color_decimals_as_signed_floats(self) -> None:
monochromatic_color_magenta_green=0.5,
)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.monochromatic_color_warm_cool == "-2.5"
assert result.monochromatic_color_magenta_green == "+0.5"
@@ -89,7 +92,7 @@ def test_defaults_absent_decimal_fields_to_zero_string(self) -> None:
# For a non-mono sim with DRP off, absent decimal fields get "0" defaults.
qr = _valid_qr()
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.highlight == "0"
assert result.shadow == "0"
@@ -100,21 +103,21 @@ def test_defaults_absent_decimal_fields_to_zero_string(self) -> None:
def test_defaults_grain_size_to_none_when_roughness_is_off(self) -> None:
qr = _valid_qr(grain_roughness="Off", grain_size=None)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.grain_size is None
def test_preserves_grain_size_when_present(self) -> None:
qr = _valid_qr(grain_roughness="Weak", grain_size="Small")
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.grain_size == "Small"
def test_defaults_colour_chrome_fields_to_off_when_absent(self) -> None:
qr = _valid_qr(color_chrome_effect=None, color_chrome_fx_blue=None)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.color_chrome_effect == "Off"
assert result.color_chrome_fx_blue == "Off"
@@ -122,7 +125,7 @@ def test_defaults_colour_chrome_fields_to_off_when_absent(self) -> None:
def test_preserves_colour_chrome_fields_when_present(self) -> None:
qr = _valid_qr(color_chrome_effect="Strong", color_chrome_fx_blue="Weak")
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.color_chrome_effect == "Strong"
assert result.color_chrome_fx_blue == "Weak"
@@ -130,21 +133,21 @@ def test_preserves_colour_chrome_fields_when_present(self) -> None:
def test_defaults_name_to_empty_when_payload_omits_it(self) -> None:
qr = _valid_qr() # name defaults to None on QRFujifilmRecipe
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.name == ""
def test_passes_name_through_when_payload_includes_it(self) -> None:
qr = _valid_qr(name="My Summer Recipe")
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.name == "My Summer Recipe"
def test_nulls_drp_fields_when_drp_is_active(self) -> None:
qr = _valid_qr(d_range_priority="Auto", dynamic_range="DR100", highlight=1, shadow=-1)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.dynamic_range is None
assert result.highlight is None
@@ -153,7 +156,7 @@ def test_nulls_drp_fields_when_drp_is_active(self) -> None:
def test_nulls_mono_fields_for_colour_sim_when_present_in_qr(self) -> None:
qr = _valid_qr(monochromatic_color_warm_cool=5.0, monochromatic_color_magenta_green=-3.0)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.monochromatic_color_warm_cool is None
assert result.monochromatic_color_magenta_green is None
@@ -168,14 +171,14 @@ def test_v1_payload_without_sensors_yields_empty_tuple(self) -> None:
# is satisfied.
qr = _valid_qr(v=1)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.sensors == ()
def test_v2_payload_with_sensors_round_trips(self) -> None:
qr = _valid_qr(v=2, sensors=("X-Trans IV", "GFX"))
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.sensors == ("X-Trans IV", "GFX")
@@ -185,6 +188,43 @@ def test_v2_payload_without_sensors_yields_empty_tuple(self) -> None:
# absent value to an empty tuple downstream.
qr = _valid_qr(v=2)
- result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr)
+ result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
assert result.sensors == ()
+
+
+class TestGetRecipeDataFromQRRecipeInvalidValues:
+ """
+ Values that are the right type but not legal.
+
+ The payload type checks let these through, and the FujifilmRecipeData
+ validators reject them with a plain ValueError. They must surface as
+ InvalidQRRecipePayloadError so a caller importing a batch of cards can
+ record the offending card as failed and carry on with the rest.
+ """
+
+ def test_raises_for_a_name_longer_than_the_maximum(self) -> None:
+ qr = _valid_qr(name="x" * (image_dataclasses.RECIPE_NAME_MAX_LEN + 1))
+
+ with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info:
+ card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
+
+ assert exc_info.value.reason == "invalid_field_value"
+ assert exc_info.value.image_path == "card.jpg"
+
+ def test_raises_for_a_non_ascii_name(self) -> None:
+ qr = _valid_qr(name="Velvia Añejo")
+
+ with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info:
+ card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
+
+ assert exc_info.value.reason == "invalid_field_value"
+
+ def test_raises_for_an_unknown_sensor_name(self) -> None:
+ # A card exported by a deployment that knows a sensor this one doesn't.
+ qr = _valid_qr(v=2, sensors=("X-Trans VI",))
+
+ with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info:
+ card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg")
+
+ assert exc_info.value.reason == "invalid_field_value"
diff --git a/tests/unit/domain/recipes/test_set_recipe_name.py b/tests/unit/domain/recipes/test_set_recipe_name.py
index 59d0d6e..1658018 100644
--- a/tests/unit/domain/recipes/test_set_recipe_name.py
+++ b/tests/unit/domain/recipes/test_set_recipe_name.py
@@ -25,12 +25,12 @@ def test_recipe_not_saved_on_invalid_name(self):
class TestSetRecipeNameEventPublishing:
- def test_publishes_recipe_image_updated_event(self, captured_logs):
+ def test_publishes_recipe_name_updated_event(self, captured_logs):
recipe = MagicMock()
recipe.pk = 42
set_recipe_name(recipe=recipe, name="My Recipe")
- updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED]
+ updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED]
assert len(updated_events) == 1
assert updated_events[0]["name"] == "My Recipe"
assert updated_events[0]["recipe_id"] == 42