diff --git a/frontend/src/app/__tests__/cattle-info.test.tsx b/frontend/src/app/__tests__/cattle-info.test.tsx index 13ab00fe..d08206fd 100644 --- a/frontend/src/app/__tests__/cattle-info.test.tsx +++ b/frontend/src/app/__tests__/cattle-info.test.tsx @@ -1218,7 +1218,7 @@ describe("Cattle Info — state-driven fields (GET /v1/animal/cattle-info-fields fillSimulationName(); await selectCountry("India"); - await pickCategory("Baby calves/heifers"); + await pickCategory("Calves"); await waitFor(() => expect(inputAfterLabel("Body Weight (BW; kg)").value).toBe("40")); for (const hidden of [ "Breed Selection", @@ -1285,7 +1285,7 @@ describe("Cattle Info — state-driven fields (GET /v1/animal/cattle-info-fields await formReady(); fillSimulationName(); await selectCountry("India"); - await pickCategory("Baby calves/heifers"); + await pickCategory("Calves"); await waitFor(() => expect(inputAfterLabel("Body Weight (BW; kg)").value).toBe("40")); fireEvent.click(screen.getByRole("button", { name: "Continue" })); diff --git a/frontend/src/components/__tests__/useCustomFeed.test.tsx b/frontend/src/components/__tests__/useCustomFeed.test.tsx new file mode 100644 index 00000000..f1ac17f7 --- /dev/null +++ b/frontend/src/components/__tests__/useCustomFeed.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +const { getCatalogCategoriesForType, getFeedCategories, getFeedTypes } = vi.hoisted(() => ({ + getCatalogCategoriesForType: vi.fn(), + getFeedCategories: vi.fn(), + getFeedTypes: vi.fn(), +})); + +vi.mock("@/lib/api", async () => { + const actual = await vi.importActual("@/lib/api"); + return { ...actual, getCatalogCategoriesForType, getFeedCategories, getFeedTypes }; +}); + +import { useCustomFeed } from "@/components/feed-selection/useCustomFeed"; +import type { User } from "@/lib/store"; + +const user = { id: "u-1", name: "A", country: "India", country_id: "1" } as User; +const MASTER = [ + "By-Product/Other", + "Plant Protein", + "Animal Protein", + "Energy Source", + "Minerals", + "Additive", + "Concentrate Mixtures", +]; + +async function categoriesFor(feedType: string) { + const showSnackbar = vi.fn(); + const { result } = renderHook(() => useCustomFeed(user, (s) => s, showSnackbar)); + await act(async () => { + await result.current.sheetProps.handleCustomTypeChange(feedType); + }); + return { categories: result.current.sheetProps.customFeedCategories, showSnackbar }; +} + +beforeEach(() => { + vi.resetAllMocks(); + // The country (India) only has feeds in Energy Source. + getFeedCategories.mockResolvedValue({ data: ["Energy Source"] }); +}); + +describe("Custom Feed categories (N6)", () => { + it("lists every master category of the type, in catalogue order, not just the country's", async () => { + getCatalogCategoriesForType.mockResolvedValue(MASTER); + const { categories } = await categoriesFor("Concentrate"); + expect(getCatalogCategoriesForType).toHaveBeenCalledWith("Concentrate"); + expect(categories).toEqual(MASTER); + expect(getFeedCategories).not.toHaveBeenCalled(); + }); + + it("falls back to the country's categories when the master lookup fails", async () => { + getCatalogCategoriesForType.mockResolvedValue(null); + const { categories, showSnackbar } = await categoriesFor("Concentrate"); + expect(getFeedCategories).toHaveBeenCalledWith("Concentrate", "1", "u-1"); + expect(categories).toEqual(["Energy Source"]); + expect(showSnackbar).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/feed-selection/useCustomFeed.ts b/frontend/src/components/feed-selection/useCustomFeed.ts index 64128083..e817140a 100644 --- a/frontend/src/components/feed-selection/useCustomFeed.ts +++ b/frontend/src/components/feed-selection/useCustomFeed.ts @@ -6,6 +6,7 @@ import { useState } from "react"; import { checkInsertOrUpdate, + getCatalogCategoriesForType, getFeedCategories, getFeedTypes, insertCustomFeed, @@ -62,9 +63,16 @@ export function useCustomFeed(user: User | null, t: (s: string) => string, showS if (!feedType || !user?.country_id || !user?.id) return; setLoadingCustomCats(true); try { - const res = await getFeedCategories(feedType, user.country_id, user.id); - const names = parseFeedCategoryOptions(res.data).map((o) => o.name); - setCustomFeedCategories(names); + // N6: a new feed may go in ANY category of its type, not only the ones + // this country already has feeds in — so the master list, in its own + // order. If that lookup fails, the country's list keeps it usable. + const catalog = await getCatalogCategoriesForType(feedType); + if (catalog) { + setCustomFeedCategories(catalog); + } else { + const res = await getFeedCategories(feedType, user.country_id, user.id); + setCustomFeedCategories(parseFeedCategoryOptions(res.data).map((o) => o.name)); + } } catch { showSnackbar(t("Could not load categories"), "error"); } finally { diff --git a/frontend/src/lib/__tests__/api-feed-taxonomy.test.ts b/frontend/src/lib/__tests__/api-feed-taxonomy.test.ts index 57c5de8a..44c69591 100644 --- a/frontend/src/lib/__tests__/api-feed-taxonomy.test.ts +++ b/frontend/src/lib/__tests__/api-feed-taxonomy.test.ts @@ -18,6 +18,7 @@ import { getFeedTypesLocalized, getFeedCategories, getFeedCategoriesLocalized, + getCatalogCategoriesForType, getFeedSubCategories, fetchFeedTaxonomyLabels, evaluateDiet, @@ -193,6 +194,20 @@ describe("getFeedCategories", () => { const res = await getFeedCategories("Complete diet", "7"); expect(res.data).toEqual({ feed_categories: ["Complete diet"] }); }); + + // N6: Custom Feed creates a feed, so it offers EVERY master category of + // the type in catalogue order; Feed Selection above keeps the overlap. + it("getCatalogCategoriesForType gives every master category, not the country overlap", async () => { + const all = ["By-Product/Other", "Plant Protein", "Energy Source", "Minerals", "Additive"]; + routeGet(["Energy Source"], { "type-conc": all.map((category_name) => ({ category_name })) }); + expect(await getCatalogCategoriesForType("Concentrate")).toEqual(all); + expect((await getFeedCategories("Concentrate", "7")).data).toEqual(["Energy Source"]); + }); + + it("getCatalogCategoriesForType returns null when the master list can't be read", async () => { + routeGet(["Energy Source"], {}, { typesFail: true }); + expect(await getCatalogCategoriesForType("Concentrate")).toBeNull(); + }); }); describe("getFeedSubCategories", () => { diff --git a/frontend/src/lib/__tests__/api-helpers.test.ts b/frontend/src/lib/__tests__/api-helpers.test.ts index 0ab3de54..8ccd002f 100644 --- a/frontend/src/lib/__tests__/api-helpers.test.ts +++ b/frontend/src/lib/__tests__/api-helpers.test.ts @@ -82,7 +82,7 @@ describe("animal category helpers", () => { expect(ANIMAL_CATEGORY_LABELS["Lactating Cow"]).toBe("Lactating cows"); expect(ANIMAL_CATEGORY_LABELS["Dry Cow"]).toBe("Dry cows"); expect(ANIMAL_CATEGORY_LABELS["Heifer"]).toBe("Heifers"); - expect(ANIMAL_CATEGORY_LABELS["Baby Calf/Heifer"]).toBe("Baby calves/heifers"); + expect(ANIMAL_CATEGORY_LABELS["Baby Calf/Heifer"]).toBe("Calves"); }); it("isLactating is true only for Lactating Cow", () => { diff --git a/frontend/src/lib/api-client/feeds.ts b/frontend/src/lib/api-client/feeds.ts index 8c174985..7d26eb74 100644 --- a/frontend/src/lib/api-client/feeds.ts +++ b/frontend/src/lib/api-client/feeds.ts @@ -167,6 +167,15 @@ async function catalogCategoryNamesForType(feed_type: string): Promise => + catalogCategoryNamesForType(feed_type); + async function allowedCategoryNamesForType(feed_type: string): Promise | null> { const names = await catalogCategoryNamesForType(feed_type); return names ? new Set(names.map(normalizeTaxonomyName)) : null; diff --git a/frontend/src/lib/api-client/models.ts b/frontend/src/lib/api-client/models.ts index e8240ec0..29b86bca 100644 --- a/frontend/src/lib/api-client/models.ts +++ b/frontend/src/lib/api-client/models.ts @@ -77,7 +77,7 @@ export const ANIMAL_CATEGORY_LABELS: Record = { "Lactating Cow": "Lactating cows", "Dry Cow": "Dry cows", Heifer: "Heifers", - "Baby Calf/Heifer": "Baby calves/heifers", + "Baby Calf/Heifer": "Calves", }; export const isLactating = (cat: AnimalCategory): boolean => cat === "Lactating Cow"; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 32220b96..614491a0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -67,6 +67,7 @@ export { checkInsertOrUpdate, fetchFeedTaxonomyLabels, getFeedCategories, + getCatalogCategoriesForType, getFeedCategoriesLocalized, getFeedClassification, getFeedDetails, diff --git a/frontend/src/lib/data/ui-labels.xlsx b/frontend/src/lib/data/ui-labels.xlsx index 87f3fdec..cd6a3d04 100644 Binary files a/frontend/src/lib/data/ui-labels.xlsx and b/frontend/src/lib/data/ui-labels.xlsx differ