diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index e75673d..2c14285 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -23,11 +23,15 @@ kotlin { sourceSets { commonMain.dependencies { // Phase 2: DTOs land here (MeResponse/User per D-27). kotlinx.serialization - // is the only allowed runtime dependency in shared/commonMain — D-19 / INFRA-06 - // forbids Ktor, Compose, SQLDelight, Koin, Kermit. `api(...)` so consumers - // (composeApp, server) inherit the @Serializable runtime without each - // re-declaring it. + // and kotlinx.datetime are the only allowed runtime dependencies in + // shared/commonMain — D-19 / INFRA-06 forbids Ktor, Compose, SQLDelight, + // Koin, Kermit. `api(...)` so consumers (composeApp, server) inherit the + // @Serializable runtime + datetime types without each re-declaring them. api(libs.kotlinx.serializationJson) + // Domain types need Instant (SyncMeta.updatedAt/createdAt/deletedAt) and + // LocalDate (PlanEntry.date). kotlinx.datetime is the project's locked + // datetime lib per CLAUDE.md; pure types, no platform deps. + api(libs.kotlinx.datetime) } commonTest.dependencies { diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Catalog.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Catalog.kt new file mode 100644 index 0000000..d453845 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Catalog.kt @@ -0,0 +1,150 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.serialization.Serializable + +// ── Value-typed metadata ────────────────────────────────────────────────── + +/** + * Macros per 100 g (or 100 ml for liquid ingredients). Daily-total aggregation + * (PLAN-13) reads these from ingredients/products and scales by the canonical + * quantity at meal time. + */ +@Serializable +public data class NutritionPer100( + public val kcal: Double, + public val protein: Double, + public val fat: Double, + public val carbs: Double, +) + +/** + * Typical retail pack for an ingredient or a specific product (e.g. + * `PurchasePack(125.0, "125 g")`). Shopping list rounding uses this to suggest + * realistic pack-sized purchases instead of raw recipe weights. + */ +@Serializable +public data class PurchasePack( + public val amount: Double, + public val label: String, +) + +// ── Household-scoped taxonomy ──────────────────────────────────────────── +// +// IngredientCategory and MealSlot are user-curated per household: each +// household ships with a Polish default list at creation (Phase 3 server +// concern) and can rename / reorder / extend freely. Both LWW-sync via SyncMeta +// the same way other household data does. + +/** + * A pantry / shopping grouping the household uses (`Pieczywo`, `Nabiał`, + * `Mięso i ryby`, …). [ordinal] drives display order in the pantry and + * shopping list. [name] is localized per the household's locale palette. + */ +@Serializable +public data class IngredientCategory( + public val id: IngredientCategoryId, + public val sync: SyncMeta, + public val name: LocalizedString, + public val ordinal: Int, +) + +/** + * A meal-time slot a household plans into (`Śniadanie`, `Drugie śniadanie`, + * `Obiad`, `Przekąska`, `Kolacja`, plus anything custom). [ordinal] drives the + * within-day order in the planner. Slots are referenced by [PlanEntry.slotId]. + * + * Recipes don't pin themselves to specific slots — slot affinity is a UI-layer + * match between [Recipe.tags] and [name] localized values, so households can + * rename slots without invalidating the catalog. + */ +@Serializable +public data class MealSlot( + public val id: MealSlotId, + public val sync: SyncMeta, + public val name: LocalizedString, + public val ordinal: Int, +) + +// ── Catalog entities (server-owned in v1, household-editable later) ────── +// +// Ingredient / Product / Recipe carry SyncMeta so that future client-side +// catalog writes land on the same LWW path as everything else; v1 only reads +// them on the client. + +/** + * A catalog-level ingredient (mąka, oliwa, jajko, …). [pantryUnit] is the + * canonical unit this ingredient is tracked in for pantry math; recipe lines + * targeting this ingredient must produce a [Quantity] in the same unit (or one + * that converts cleanly — pieces ↔ grams via [weightPerPieceG]). + * + * [weightPerPieceG] is only meaningful when [pantryUnit] == [MeasurementUnit.PIECE]; + * leaving it null on a piece-tracked ingredient blocks nutrition aggregation. + * [purchasePack] feeds shopping rounding; [nutritionPer100] feeds daily totals. + */ +@Serializable +public data class Ingredient( + public val id: IngredientId, + public val sync: SyncMeta, + public val name: LocalizedString, + public val categoryId: IngredientCategoryId, + public val pantryUnit: MeasurementUnit, + public val weightPerPieceG: Double? = null, + public val purchasePack: PurchasePack? = null, + public val nutritionPer100: NutritionPer100? = null, + public val imagePath: String? = null, +) + +/** + * A branded / packaged variant of an [Ingredient]. Products carry their own + * [nutritionPer100] because brand A's twaróg has different macros than brand + * B's, and per-entry [PlanCustomization.overrides] can pick a specific product. + * [brand] is plain `String?` — brand names are proper nouns and don't translate. + */ +@Serializable +public data class Product( + public val id: ProductId, + public val sync: SyncMeta, + public val ingredientId: IngredientId, + public val name: LocalizedString, + public val brand: String? = null, + public val pack: PurchasePack, + public val nutritionPer100: NutritionPer100? = null, + public val imagePath: String? = null, +) + +/** + * One ingredient line on a recipe. [alternatives] is metadata only — the + * planner UI surfaces these as suggested swaps, but the actual per-entry + * substitution is recorded in [IngredientCustomization.substituteWith]. + */ +@Serializable +public data class RecipeIngredient( + public val ingredientId: IngredientId, + public val quantity: Quantity, + public val alternatives: List = emptyList(), +) + +/** + * Catalog-level recipe definition. Synced via the same LWW path as household + * data so future client-side recipe editing lands additively. Recipes are + * server-seeded in v1 and read-only on the client. + * + * No `allowedSlots` field — slot affinity is a UI-layer concern using [tags] + * matched against [MealSlot.name] localized values. Recipes ship with Polish + * slot tags so default households work out of the box. + * + * [nutritionPerServing] is the cached pre-customization total; the calculator + * skips the per-ingredient walk when no [PlanCustomization] is present. + */ +@Serializable +public data class Recipe( + public val id: RecipeId, + public val sync: SyncMeta, + public val title: LocalizedString, + public val minutes: Int, + public val tags: List = emptyList(), + public val steps: List = emptyList(), + public val ingredients: List, + public val nutritionPerServing: NutritionPer100, + public val imagePath: String? = null, +) diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Ids.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Ids.kt new file mode 100644 index 0000000..c90989e --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Ids.kt @@ -0,0 +1,54 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlin.jvm.JvmInline +import kotlinx.serialization.Serializable + +// Typed id wrappers. `@Serializable @JvmInline value class` serializes as the +// underlying string in JSON (no `{"raw":"..."}` envelope) and erases to a +// String at runtime on JVM/Native — zero wire-format change, zero overhead, +// full compile-time protection against passing a RecipeId where an +// IngredientId is expected. + +@Serializable +@JvmInline +public value class HouseholdId(public val raw: String) + +@Serializable +@JvmInline +public value class UserId(public val raw: String) + +@Serializable +@JvmInline +public value class RecipeId(public val raw: String) + +@Serializable +@JvmInline +public value class IngredientId(public val raw: String) + +@Serializable +@JvmInline +public value class ProductId(public val raw: String) + +@Serializable +@JvmInline +public value class PlanEntryId(public val raw: String) + +@Serializable +@JvmInline +public value class PantryItemId(public val raw: String) + +@Serializable +@JvmInline +public value class ShoppingListId(public val raw: String) + +@Serializable +@JvmInline +public value class ShoppingItemId(public val raw: String) + +@Serializable +@JvmInline +public value class IngredientCategoryId(public val raw: String) + +@Serializable +@JvmInline +public value class MealSlotId(public val raw: String) diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Inventory.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Inventory.kt new file mode 100644 index 0000000..c0a4cdf --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Inventory.kt @@ -0,0 +1,52 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.serialization.Serializable + +/** + * One stock row for an ingredient — optionally pinned to a specific [Product]. + * + * The mockup's hybrid `Number-or-{total, items[]}` storage collapses to + * multiple flat rows: one with `productId == null` for generic stock, plus a + * row per tracked product. Aggregating "how much of X do I have?" is a sum + * across the rows sharing an [ingredientId]. + */ +@Serializable +public data class PantryItem( + public val id: PantryItemId, + public val sync: SyncMeta, + public val ingredientId: IngredientId, + public val productId: ProductId? = null, + public val quantity: Quantity, +) + +/** + * A named shopping list (most households use the default "Kitchen" list; + * additional lists are user-created). [ordinal] orders multiple lists in the + * UI. [name] is plain `String` — user-supplied free text, not catalog-localized. + */ +@Serializable +public data class ShoppingList( + public val id: ShoppingListId, + public val sync: SyncMeta, + public val name: String, + public val ordinal: Int, +) + +/** + * One line on a shopping list. [productId] pins a specific brand when present. + * [checked] tracks "bought" state; checking + later sync into pantry happens at + * the repository layer. [sourceNote] is a free-text provenance hint ("From + * plan", "Ze spiżarni"); the v1 UI surfaces it but doesn't filter on it. + */ +@Serializable +public data class ShoppingItem( + public val id: ShoppingItemId, + public val sync: SyncMeta, + public val listId: ShoppingListId, + public val ingredientId: IngredientId, + public val productId: ProductId? = null, + public val quantity: Quantity, + public val checked: Boolean = false, + public val sourceNote: String? = null, + public val ordinal: Int, +) diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Localization.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Localization.kt new file mode 100644 index 0000000..979a028 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Localization.kt @@ -0,0 +1,35 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlin.jvm.JvmInline +import kotlinx.serialization.Serializable + +/** + * BCP-47 locale tag, e.g. `"pl"`, `"en"`, `"pl-PL"`. Held as a typed value + * class so localized-name maps don't get confused with arbitrary string keys. + */ +@Serializable +@JvmInline +public value class LocaleTag(public val raw: String) + +/** + * A user-facing string in one or more locales. Stored as a flat JSON object + * keyed by locale tag (e.g. `{"pl": "Śniadanie", "en": "Breakfast"}`). v1 seed + * data ships with at least the Polish key populated; additional locales are + * additive. + */ +public typealias LocalizedString = Map + +/** + * Resolve a localized string for the requested [locale], falling back to + * [fallback] (default Polish), then to any populated value, then to an empty + * string. The empty-string fallback is intentional — UI code should never crash + * because a translation hasn't shipped yet. + */ +public fun LocalizedString.forLocale( + locale: LocaleTag, + fallback: LocaleTag = LocaleTag("pl"), +): String = + this[locale] + ?: this[fallback] + ?: values.firstOrNull() + ?: "" diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt new file mode 100644 index 0000000..b781570 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Planner.kt @@ -0,0 +1,63 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.datetime.LocalDate +import kotlinx.serialization.Serializable + +/** + * Per-ingredient overrides applied to a planned meal. All fields are optional; + * an entry is only stored in [PlanCustomization.overrides] when at least one + * field is non-default. The repository prunes default-only entries on write. + * + * The four fields cover PLAN-07 through PLAN-11 (substitutions, exclusions, + * amount overrides, product picks) collapsed onto a single per-ingredient row + * so they can compose — substituting almonds for walnuts AND overriding the + * amount AND pinning a brand all land on the same record. + */ +@Serializable +public data class IngredientCustomization( + public val substituteWith: IngredientId? = null, + public val excluded: Boolean = false, + public val amountOverride: Quantity? = null, + public val productPick: ProductId? = null, +) + +/** + * An ingredient injected into a planned meal that isn't part of the original + * recipe (PLAN-10). [productPick] optionally pins a specific [Product]. + */ +@Serializable +public data class AddedIngredient( + public val ingredientId: IngredientId, + public val quantity: Quantity, + public val productPick: ProductId? = null, +) + +/** + * All customizations attached to a single [PlanEntry]. Defaults are empty — + * a Phase 6 entry without customizations stays cheap. + */ +@Serializable +public data class PlanCustomization( + public val overrides: Map = emptyMap(), + public val added: List = emptyList(), +) + +/** + * One planned meal: a recipe at a date/slot pair. Identity is the UUID + * [PlanEntryId] — never the composite `(date, slot)` — so concurrent edits + * across devices can survive a delete + recreate race without colliding on a + * natural key (PLAN-14). + * + * "Skipped slot" (PLAN-12) is modeled as absence — no [PlanEntry] row exists + * for that `(date, slotId)`. No sentinel field, no sealed sibling. + */ +@Serializable +public data class PlanEntry( + public val id: PlanEntryId, + public val sync: SyncMeta, + public val date: LocalDate, + public val slotId: MealSlotId, + public val recipeId: RecipeId, + public val servings: Double, + public val customization: PlanCustomization = PlanCustomization(), +) diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Sync.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Sync.kt new file mode 100644 index 0000000..55e12a2 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Sync.kt @@ -0,0 +1,36 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +/** + * Per-row sync footer for every household-scoped or catalog entity. + * + * All three timestamps are server-assigned, never client-supplied — the + * monotonic `(updatedAt, id)` pair is the pull cursor, and a client-set + * `updatedAt` would break LWW conflict resolution across devices with drifted + * clocks. [deletedAt] is the soft-delete tombstone; rows with `deletedAt != null` + * still ship through sync so deletes propagate to other devices. + */ +@Serializable +public data class SyncMeta( + public val id: String, + public val householdId: HouseholdId, + public val createdAt: Instant, + public val updatedAt: Instant, + public val deletedAt: Instant? = null, +) + +/** + * A household — the tenancy boundary for every per-household entity (plan, + * pantry, shopping list, the household-scoped taxonomy entries in + * [IngredientCategory] / [MealSlot]). Members share a single household; v1 has + * one active household per user. + */ +@Serializable +public data class Household( + public val id: HouseholdId, + public val name: String, + public val createdAt: Instant, + public val updatedAt: Instant, +) diff --git a/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Units.kt b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Units.kt new file mode 100644 index 0000000..6396296 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/ulfrx/recipe/shared/domain/model/Units.kt @@ -0,0 +1,39 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The three canonical units the whole system computes on. Polish vocabulary + * like `"łyżka"`, `"puszka"`, `"szczypta"` is NOT a unit — it lives in + * [Quantity.displayHint] as render-only metadata while the math runs on this + * fixed enum. + */ +@Serializable +public enum class MeasurementUnit { + @SerialName("gram") + GRAM, + + @SerialName("milliliter") + MILLILITER, + + @SerialName("piece") + PIECE, +} + +/** + * Canonical [amount] in a fixed [unit], with an optional human-readable + * [displayHint] for UI rendering. + * + * The hint lets a recipe say "2 łyżki oleju" (stored as + * `Quantity(30.0, MILLILITER, {"pl": "2 łyżki"})`) and still aggregate cleanly + * with another row's `100ml` because both are millilitres internally. Catalog + * ingest is responsible for choosing the canonical amount; the domain model + * just stores the result. + */ +@Serializable +public data class Quantity( + public val amount: Double, + public val unit: MeasurementUnit, + public val displayHint: LocalizedString? = null, +) diff --git a/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt new file mode 100644 index 0000000..777589b --- /dev/null +++ b/shared/src/commonTest/kotlin/dev/ulfrx/recipe/shared/domain/model/SerializationRoundTripTest.kt @@ -0,0 +1,283 @@ +package dev.ulfrx.recipe.shared.domain.model + +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Wire-format contract for the reshaped domain model. + * + * The sync engine (Phase 4) stores these shapes verbatim into Postgres JSONB + * columns; the planner UI (Phase 6/7) round-trips them through SQLDelight; a + * future client-side catalog editor would write through the same path. So the + * JSON keys, value-class flattening, enum casing, and default-omission behavior + * asserted below are load-bearing. + */ +class SerializationRoundTripTest { + private val json = Json { encodeDefaults = false } + + private val pl = LocaleTag("pl") + private val en = LocaleTag("en") + + // ── IDs ────────────────────────────────────────────────────────────── + + @Test + fun `typed IDs serialize as bare strings instead of wrapper objects`() { + val encoded = json.encodeToString(RecipeId.serializer(), RecipeId("rcp_007")) + + assertEquals("\"rcp_007\"", encoded) + + val decoded = json.decodeFromString(RecipeId.serializer(), encoded) + assertEquals(RecipeId("rcp_007"), decoded) + } + + // ── Localization ───────────────────────────────────────────────────── + + @Test + fun `LocaleTag serializes as a bare string key`() { + // LocaleTag must serialize as a string so LocalizedString JSON keys + // read like {"pl":"Śniadanie"} — not {"raw":"pl"} wrappers. + val encoded = json.encodeToString(LocaleTag.serializer(), pl) + assertEquals("\"pl\"", encoded) + } + + @Test + fun `LocalizedString forLocale falls back to Polish then to any value`() { + val name: LocalizedString = mapOf(pl to "Śniadanie", en to "Breakfast") + assertEquals("Śniadanie", name.forLocale(pl)) + assertEquals("Breakfast", name.forLocale(en)) + + val onlyPolish: LocalizedString = mapOf(pl to "Obiad") + assertEquals("Obiad", onlyPolish.forLocale(en)) + + val empty: LocalizedString = emptyMap() + assertEquals("", empty.forLocale(pl)) + } + + // ── Units ──────────────────────────────────────────────────────────── + + @Test + fun `MeasurementUnit serializes as lowercase English wire names`() { + assertEquals("\"gram\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.GRAM)) + assertEquals("\"milliliter\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.MILLILITER)) + assertEquals("\"piece\"", json.encodeToString(MeasurementUnit.serializer(), MeasurementUnit.PIECE)) + } + + @Test + fun `Quantity with displayHint encodes hint as flat locale map`() { + val q = Quantity(amount = 30.0, unit = MeasurementUnit.MILLILITER, displayHint = mapOf(pl to "2 łyżki")) + val encoded = json.encodeToString(Quantity.serializer(), q) + + assertEquals( + "{\"amount\":30.0,\"unit\":\"milliliter\",\"displayHint\":{\"pl\":\"2 łyżki\"}}", + encoded, + ) + + val decoded = json.decodeFromString(Quantity.serializer(), encoded) + assertEquals(q, decoded) + } + + @Test + fun `Quantity without displayHint omits the null field on the wire`() { + // Phase 4 syncs these into JSONB; omitting nulls keeps the payload + // small and lets future fields default cleanly on the decoder side. + val q = Quantity(amount = 100.0, unit = MeasurementUnit.GRAM) + val encoded = json.encodeToString(Quantity.serializer(), q) + + assertEquals("{\"amount\":100.0,\"unit\":\"gram\"}", encoded) + assertEquals(q, json.decodeFromString(Quantity.serializer(), encoded)) + } + + // ── PlanEntry — the full customization shape ───────────────────────── + + @Test + fun `PlanEntry with every customization kind round trips`() { + val flour = IngredientId("ing_flour") + val butter = IngredientId("ing_butter") + val margarine = IngredientId("ing_margarine") + val salt = IngredientId("ing_salt") + val oil = IngredientId("ing_oil") + val sugar = IngredientId("ing_sugar") + val piatnica = ProductId("prd_piatnica_serek") + + val entry = + PlanEntry( + id = PlanEntryId("pe_001"), + sync = + SyncMeta( + id = "pe_001", + householdId = HouseholdId("hh_aaa"), + createdAt = Instant.parse("2026-05-19T08:00:00Z"), + updatedAt = Instant.parse("2026-05-19T08:00:00Z"), + ), + date = LocalDate(2026, 5, 19), + slotId = MealSlotId("slot_breakfast"), + recipeId = RecipeId("rcp_pancakes"), + servings = 2.0, + customization = + PlanCustomization( + overrides = + mapOf( + // Substitute butter → margarine + butter to IngredientCustomization(substituteWith = margarine), + // Exclude salt entirely + salt to IngredientCustomization(excluded = true), + // Override flour to 250g (use displayHint to prove it round-trips) + flour to + IngredientCustomization( + amountOverride = + Quantity( + amount = 250.0, + unit = MeasurementUnit.GRAM, + displayHint = mapOf(pl to "2 szklanki"), + ), + ), + // Pin a specific product + sugar to IngredientCustomization(productPick = piatnica), + ), + added = + listOf( + AddedIngredient( + ingredientId = oil, + quantity = + Quantity( + amount = 15.0, + unit = MeasurementUnit.MILLILITER, + displayHint = mapOf(pl to "1 łyżka"), + ), + ), + ), + ), + ) + + val encoded = json.encodeToString(PlanEntry.serializer(), entry) + val decoded = json.decodeFromString(PlanEntry.serializer(), encoded) + + assertEquals(entry, decoded) + + // Spot-check wire shape: typed IDs are bare strings inside the payload + // (otherwise the JSONB column gets ugly and queries are unportable). + assertTrue(encoded.contains("\"recipeId\":\"rcp_pancakes\""), "recipeId should serialize as a bare string: $encoded") + assertTrue(encoded.contains("\"slotId\":\"slot_breakfast\""), "slotId should serialize as a bare string: $encoded") + } + + @Test + fun `empty PlanCustomization omits with encodeDefaults off`() { + val empty = PlanCustomization() + val encoded = json.encodeToString(PlanCustomization.serializer(), empty) + + // With encodeDefaults = false, both fields fall back to their empty + // defaults and the wire payload is `{}`. Phase 6 PlanEntry rows without + // customizations get the cheapest possible JSONB representation. + assertEquals("{}", encoded) + assertEquals(empty, json.decodeFromString(PlanCustomization.serializer(), encoded)) + } + + @Test + fun `IngredientCustomization with all defaults omits every field`() { + val empty = IngredientCustomization() + val encoded = json.encodeToString(IngredientCustomization.serializer(), empty) + + // Repository contract: prune fully-default entries before write — but + // if one slips through, the wire representation is still empty. + assertEquals("{}", encoded) + } + + // ── Catalog ────────────────────────────────────────────────────────── + + @Test + fun `Ingredient with multi-locale name and required pantryUnit round trips`() { + val ingredient = + Ingredient( + id = IngredientId("ing_olej"), + sync = + SyncMeta( + id = "ing_olej", + householdId = HouseholdId("hh_aaa"), + createdAt = Instant.parse("2026-04-01T00:00:00Z"), + updatedAt = Instant.parse("2026-04-01T00:00:00Z"), + ), + name = mapOf(pl to "Olej rzepakowy", en to "Rapeseed oil"), + categoryId = IngredientCategoryId("cat_suche"), + pantryUnit = MeasurementUnit.MILLILITER, + purchasePack = PurchasePack(amount = 1000.0, label = "1 l"), + nutritionPer100 = NutritionPer100(kcal = 884.0, protein = 0.0, fat = 100.0, carbs = 0.0), + ) + + val encoded = json.encodeToString(Ingredient.serializer(), ingredient) + val decoded = json.decodeFromString(Ingredient.serializer(), encoded) + + assertEquals(ingredient, decoded) + assertTrue( + encoded.contains("\"name\":{\"pl\":\"Olej rzepakowy\",\"en\":\"Rapeseed oil\"}"), + "LocalizedString should serialize as a flat locale-keyed object: $encoded", + ) + assertTrue(encoded.contains("\"pantryUnit\":\"milliliter\"")) + assertTrue(!encoded.contains("\"weightPerPieceG\""), "null weightPerPieceG should be omitted: $encoded") + assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded") + } + + @Test + fun `Recipe drops null imagePath but keeps populated localized title and steps`() { + val recipe = + Recipe( + id = RecipeId("rcp_naleśniki"), + sync = + SyncMeta( + id = "rcp_naleśniki", + householdId = HouseholdId("hh_aaa"), + createdAt = Instant.parse("2026-04-01T00:00:00Z"), + updatedAt = Instant.parse("2026-04-01T00:00:00Z"), + ), + title = mapOf(pl to "Naleśniki", en to "Pancakes"), + minutes = 25, + tags = listOf("śniadanie", "szybkie"), + steps = + listOf( + mapOf(pl to "Wymieszaj mąkę z mlekiem.", en to "Mix flour with milk."), + mapOf(pl to "Smaż na patelni.", en to "Fry on a pan."), + ), + ingredients = + listOf( + RecipeIngredient( + ingredientId = IngredientId("ing_flour"), + quantity = Quantity(amount = 200.0, unit = MeasurementUnit.GRAM), + ), + ), + nutritionPerServing = NutritionPer100(kcal = 320.0, protein = 8.0, fat = 9.0, carbs = 52.0), + ) + + val encoded = json.encodeToString(Recipe.serializer(), recipe) + val decoded = json.decodeFromString(Recipe.serializer(), encoded) + + assertEquals(recipe, decoded) + assertTrue(!encoded.contains("\"imagePath\""), "null imagePath should be omitted: $encoded") + assertTrue(encoded.contains("\"tags\":[\"śniadanie\",\"szybkie\"]")) + } + + // ── Sync ───────────────────────────────────────────────────────────── + + @Test + fun `SyncMeta omits null deletedAt and surfaces all three timestamps`() { + val sync = + SyncMeta( + id = "pe_001", + householdId = HouseholdId("hh_aaa"), + createdAt = Instant.parse("2026-05-19T08:00:00Z"), + updatedAt = Instant.parse("2026-05-19T08:15:00Z"), + ) + + val encoded = json.encodeToString(SyncMeta.serializer(), sync) + + assertTrue(encoded.contains("\"createdAt\":\"2026-05-19T08:00:00Z\"")) + assertTrue(encoded.contains("\"updatedAt\":\"2026-05-19T08:15:00Z\"")) + assertTrue(!encoded.contains("\"deletedAt\""), "null deletedAt should be omitted: $encoded") + + val tombstoned = sync.copy(deletedAt = Instant.parse("2026-05-20T09:00:00Z")) + val tombstoneEncoded = json.encodeToString(SyncMeta.serializer(), tombstoned) + assertTrue(tombstoneEncoded.contains("\"deletedAt\":\"2026-05-20T09:00:00Z\"")) + } +}