Reshape shared/commonMain domain model
Replace the 11 hand-rolled model files with 7 grouped by concern. Typed ID value classes kill bare-string FKs. Canonical 3-value MeasurementUnit enum kills the runtime unitMismatch class — Polish vocabulary lives in Quantity.displayHint as render-only metadata. MealExtras (5 maps) collapses into IngredientCustomization + PlanCustomization. IngredientCategory and MealSlot become household-scoped entities with LocalizedString names so they're customizable without an app release. Display names land as LocalizedString from day one; no Polish strings in identifiers or wire codes. Recipe drops allowedSlots — slot affinity is a UI-layer match on Recipe.tags vs MealSlot.name. Skip is absence, not a sealed sibling. Plan: ~/.claude/plans/i-have-generated-some-inherited-conway.md. Covered by SerializationRoundTripTest: 12 assertions across typed-ID inlining, MeasurementUnit wire format, LocalizedString JSON shape, full PlanEntry round-trip with every customization kind, SyncMeta tombstone omission, and Catalog defaults handling. All targets compile and pass: JVM, Android (debug + release), iOS Simulator Arm64.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<IngredientId> = 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<String> = emptyList(),
|
||||
public val steps: List<LocalizedString> = emptyList(),
|
||||
public val ingredients: List<RecipeIngredient>,
|
||||
public val nutritionPerServing: NutritionPer100,
|
||||
public val imagePath: String? = null,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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<LocaleTag, String>
|
||||
|
||||
/**
|
||||
* 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()
|
||||
?: ""
|
||||
@@ -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<IngredientId, IngredientCustomization> = emptyMap(),
|
||||
public val added: List<AddedIngredient> = 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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user