+
{label}
{value}
diff --git a/apps/desktop/test/e2e/smoke.spec.ts b/apps/desktop/test/e2e/smoke.spec.ts
index 85e35f0..0159685 100644
--- a/apps/desktop/test/e2e/smoke.spec.ts
+++ b/apps/desktop/test/e2e/smoke.spec.ts
@@ -342,6 +342,19 @@ test.describe("Recipe editor — hops", () => {
});
});
+test.describe("Library cards", () => {
+ test("fill ABV, IBU and color from estimates when the file lacks them (#49)", async ({ page }) => {
+ const app = new App(page);
+ await app.go("Library");
+ await app.library.importSamples();
+ // The Punk IPA sample has OG and FG but no ABV, IBU or color.
+ const card = page.locator("li", { has: page.locator("h2", { hasText: /punk ipa/i }) });
+ await expect(card.getByTestId("card-stat-abv").locator("dd")).toHaveText(/^\d+\.\d%$/);
+ await expect(card.getByTestId("card-stat-ibu").locator("dd")).toHaveText(/^\d+$/);
+ await expect(card.getByTestId("card-color")).toBeVisible();
+ });
+});
+
test.describe("Discoverability", () => {
test("Library onboarding mentions local storage + GitHub sync", async ({ page }) => {
const app = new App(page);
diff --git a/apps/desktop/test/library-card.test.tsx b/apps/desktop/test/library-card.test.tsx
new file mode 100644
index 0000000..208e02b
--- /dev/null
+++ b/apps/desktop/test/library-card.test.tsx
@@ -0,0 +1,53 @@
+import { afterEach, describe, it, expect } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { BeerJsonFile, BeerJsonRecipe } from "@werb/adapters";
+import { RecipeCard } from "../src/screens/Library.tsx";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const load = (file: string): BeerJsonRecipe =>
+ (JSON.parse(readFileSync(join(__dirname, "../../../examples", file), "utf8")) as BeerJsonFile)
+ .beerjson.recipes![0]!;
+
+const noop = () => {};
+const card = (recipe: BeerJsonRecipe) =>
+ render();
+const stat = (id: string) => screen.getByTestId(id).querySelector("dd")!.textContent ?? "";
+
+describe("RecipeCard", () => {
+ afterEach(cleanup);
+
+ it("fills ABV, IBU and color from Werb's estimates when the file lacks them (#49)", () => {
+ card(load("brewdog-punk-ipa-2007.beerjson"));
+ expect(stat("card-stat-og")).toBe("1.056");
+ expect(stat("card-stat-fg")).toBe("1.010");
+ expect(stat("card-stat-abv")).toMatch(/^\d+\.\d%$/);
+ expect(stat("card-stat-ibu")).toMatch(/^\d+$/);
+ expect(screen.getByTestId("card-color")).toBeTruthy();
+ });
+
+ it("shows the file's values, with Werb's IBU estimate alongside", () => {
+ const recipe = load("cascade-pale-ale.beerjson");
+ card(recipe);
+ expect(stat("card-stat-abv")).toBe(`${recipe.alcohol_by_volume!.value.toFixed(1)}%`);
+ expect(stat("card-stat-ibu")).toMatch(new RegExp(`^${recipe.ibu_estimate!.ibu!.value}≈\\d+$`));
+ });
+
+ it("shows dashes when there is nothing to estimate from", () => {
+ const empty = {
+ ...load("cascade-pale-ale.beerjson"),
+ original_gravity: undefined,
+ final_gravity: undefined,
+ alcohol_by_volume: undefined,
+ ibu_estimate: undefined,
+ color_estimate: undefined,
+ ingredients: { fermentable_additions: [] },
+ } as unknown as BeerJsonRecipe;
+ card(empty);
+ expect(stat("card-stat-og")).toBe("—");
+ expect(stat("card-stat-abv")).toBe("—");
+ expect(screen.queryByTestId("card-color")).toBeNull();
+ });
+});
diff --git a/apps/desktop/test/recipe-targets.test.ts b/apps/desktop/test/recipe-targets.test.ts
new file mode 100644
index 0000000..59add60
--- /dev/null
+++ b/apps/desktop/test/recipe-targets.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { BeerJsonFile, BeerJsonRecipe } from "@werb/adapters";
+import { recipeTargets, shownValue } from "../src/data/recipe-targets.ts";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const load = (file: string): BeerJsonRecipe =>
+ (JSON.parse(readFileSync(join(__dirname, "../../../examples", file), "utf8")) as BeerJsonFile)
+ .beerjson.recipes![0]!;
+
+// Punk IPA ships OG and FG but no ABV, IBU or color.
+const punk = load("brewdog-punk-ipa-2007.beerjson");
+const cascade = load("cascade-pale-ale.beerjson");
+
+describe("recipeTargets", () => {
+ it("estimates ABV from the file's OG and FG when the file has none", () => {
+ const t = recipeTargets(punk, "Morey");
+ expect(t.abv.claimed).toBeNull();
+ // (1.056 − 1.010) × 131.25 ≈ 6.0 %
+ expect(shownValue(t.abv)).toBeCloseTo(6.0, 1);
+ });
+
+ it("estimates the color from the grain bill when the file has none", () => {
+ const t = recipeTargets(punk, "Morey");
+ expect(t.srm.claimed).toBeNull();
+ expect(shownValue(t.srm)).toBeGreaterThan(0);
+ });
+
+ it("shows the file's values first", () => {
+ const t = recipeTargets(cascade, "Morey");
+ expect(shownValue(t.og)).toBe(cascade.original_gravity!.value);
+ expect(shownValue(t.fg)).toBe(cascade.final_gravity!.value);
+ expect(shownValue(t.abv)).toBe(cascade.alcohol_by_volume!.value);
+ expect(t.og.computed).toBeGreaterThan(1);
+ });
+
+ it("estimates OG and FG from the ingredients when the file has none", () => {
+ const { original_gravity: _og, final_gravity: _fg, alcohol_by_volume: _abv, ...bare } = cascade;
+ const t = recipeTargets(bare, "Morey");
+ expect(shownValue(t.og)).toBeGreaterThan(1.03);
+ expect(shownValue(t.fg)!).toBeLessThan(shownValue(t.og)!);
+ expect(shownValue(t.abv)).toBeGreaterThan(3);
+ });
+
+ it("no estimate without fermentables", () => {
+ const empty: BeerJsonRecipe = {
+ ...cascade,
+ original_gravity: undefined,
+ final_gravity: undefined,
+ alcohol_by_volume: undefined,
+ color_estimate: undefined,
+ ingredients: { ...cascade.ingredients, fermentable_additions: [] },
+ } as unknown as BeerJsonRecipe;
+ const t = recipeTargets(empty, "Morey");
+ expect(shownValue(t.og)).toBeNull();
+ expect(shownValue(t.fg)).toBeNull();
+ expect(shownValue(t.abv)).toBeNull();
+ expect(shownValue(t.srm)).toBeNull();
+ });
+});