From 065d16b211cc8887c17ff2e5d5267d8a620a88f7 Mon Sep 17 00:00:00 2001 From: kiranannadatha8 Date: Sun, 19 Apr 2026 01:11:23 -0400 Subject: [PATCH 1/2] feat(nutrition): filter ingredient search by Nutri-Score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Nutri-Score filter to the ingredient autocompleter. When enabled, a Material-UI slider lets the user pick the worst acceptable grade (A–E); the value is sent to the backend as `nutriscore__lte` so that only ingredients at or better than that grade are returned. * refactor `searchIngredient` to accept an `IngredientSearchFilters` object instead of five positional parameters — this makes it safe to add further optional filters without shifting call sites * persist the toggle state and selected grade in localStorage using the same pattern as the existing vegan / vegetarian / language filters * add 5 tests covering slider visibility, localStorage round-trip, and the resulting API call shape; clear localStorage in beforeEach to prevent leakage between tests Depends on wger-project/wger PR #2305 which adds the `nutriscore__lte` lookup to the backend filterset. Refs #2295 --- public/locales/en/translation.json | 2 + .../widgets/IngredientAutcompleter.tsx | 72 ++++++++++-- .../widgets/IngredientAutocompleter.test.tsx | 104 ++++++++++++++++++ src/services/ingredient.ts | 29 ++++- 4 files changed, 194 insertions(+), 13 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index b18e8f7d..67517954 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -140,6 +140,8 @@ "languageFilterAll": "All languages", "filterVegan": "Vegan", "filterVegetarian": "Vegetarian", + "filterNutriscore": "Filter by Nutri-Score", + "filterNutriscoreMax": "Worst acceptable grade", "macronutrient": "Macronutrient", "percentEnergy": "Percent of energy", "gPerBodyKg": "g per body-kg", diff --git a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx index 3b4a3098..d48515c7 100644 --- a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx +++ b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx @@ -18,9 +18,11 @@ import { MenuItem, Popover, Select, + Slider, Stack, Switch, TextField, + Typography, } from "@mui/material"; import { Ingredient } from "components/Nutrition/models/Ingredient"; import { NutriScoreBadge } from "components/Nutrition/widgets/NutriScoreBadge"; @@ -29,6 +31,7 @@ import * as React from 'react'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from "react-i18next"; import { IngredientLanguageFilter, searchIngredient } from "services"; +import { NUTRI_SCORES, NutriScoreValue } from "types"; import { LANGUAGE_SHORT_ENGLISH } from "utils/consts"; type IngredientAutocompleterProps = { @@ -39,6 +42,11 @@ type IngredientAutocompleterProps = { export const STORAGE_KEY_LANGUAGE_FILTER = "wger.ingredientSearch.languageFilter"; export const STORAGE_KEY_VEGAN = "wger.ingredientSearch.filterVegan"; export const STORAGE_KEY_VEGETARIAN = "wger.ingredientSearch.filterVegetarian"; +export const STORAGE_KEY_NUTRISCORE_ENABLED = "wger.ingredientSearch.filterNutriscoreEnabled"; +export const STORAGE_KEY_NUTRISCORE_MAX = "wger.ingredientSearch.filterNutriscoreMax"; + +const isNutriScoreValue = (value: string | null): value is NutriScoreValue => + value !== null && (NUTRI_SCORES as readonly string[]).includes(value); export function IngredientAutocompleter({ callback, initialIngredient }: IngredientAutocompleterProps) { const initialData = initialIngredient ?? null; @@ -57,6 +65,13 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi const [filterVegetarian, setFilterVegetarian] = useState(() => { return localStorage.getItem(STORAGE_KEY_VEGETARIAN) === "true"; }); + const [filterNutriscoreEnabled, setFilterNutriscoreEnabled] = useState(() => { + return localStorage.getItem(STORAGE_KEY_NUTRISCORE_ENABLED) === "true"; + }); + const [nutriscoreMax, setNutriscoreMax] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY_NUTRISCORE_MAX); + return isNutriScoreValue(stored) ? stored : "c"; + }); const [filtersAnchorEl, setFiltersAnchorEl] = useState(null); const [value, setValue] = useState(initialData); const [inputValue, setInputValue] = useState(""); @@ -80,6 +95,14 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi localStorage.setItem(STORAGE_KEY_VEGETARIAN, String(filterVegetarian)); }, [filterVegetarian]); + useEffect(() => { + localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, String(filterNutriscoreEnabled)); + }, [filterNutriscoreEnabled]); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, nutriscoreMax); + }, [nutriscoreMax]); + const languageOptions = useMemo(() => { const options: Array<{ value: IngredientLanguageFilter; label: string }> = [ { @@ -107,16 +130,16 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi () => debounce( (request: string) => - searchIngredient( - request, - i18n.language, + searchIngredient(request, { + languageCode: i18n.language, languageFilter, - filterVegan || undefined, - filterVegetarian || undefined, - ).then((res) => setOptions(res)), + isVegan: filterVegan || undefined, + isVegetarian: filterVegetarian || undefined, + nutriscoreMax: filterNutriscoreEnabled ? nutriscoreMax : undefined, + }).then((res) => setOptions(res)), 200 ), - [i18n.language, languageFilter, filterVegan, filterVegetarian] + [i18n.language, languageFilter, filterVegan, filterVegetarian, filterNutriscoreEnabled, nutriscoreMax] ); useEffect(() => { @@ -297,6 +320,41 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi label={t("nutrition.filterVegetarian")} /> + + + setFilterNutriscoreEnabled(checked)} + /> + } + label={t("nutrition.filterNutriscore")} + /> + + + {filterNutriscoreEnabled && ( + + + {t("nutrition.filterNutriscoreMax")} + + + setNutriscoreMax(NUTRI_SCORES[value as number]) + } + getAriaValueText={(value) => NUTRI_SCORES[value].toUpperCase()} + step={1} + min={0} + max={NUTRI_SCORES.length - 1} + marks={NUTRI_SCORES.map((score, index) => ({ + value: index, + label: score.toUpperCase(), + }))} + /> + + )} diff --git a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx index 20619221..a42f0575 100644 --- a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx +++ b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx @@ -3,6 +3,8 @@ import userEvent from "@testing-library/user-event"; import { IngredientAutocompleter, STORAGE_KEY_LANGUAGE_FILTER, + STORAGE_KEY_NUTRISCORE_ENABLED, + STORAGE_KEY_NUTRISCORE_MAX, STORAGE_KEY_VEGAN, STORAGE_KEY_VEGETARIAN } from 'components/Nutrition/widgets/IngredientAutcompleter'; @@ -16,6 +18,7 @@ describe("Test the IngredientAutocompleter component", () => { // Arrange const mockCallback = jest.fn(); beforeEach(() => { + localStorage.clear(); (searchIngredient as jest.Mock).mockImplementation(() => Promise.resolve([TEST_INGREDIENT_1, TEST_INGREDIENT_2])); }); @@ -186,4 +189,105 @@ describe("Test the IngredientAutocompleter component", () => { expect(screen.getByText('nutrition.languageFilterAll')).toBeInTheDocument(); localStorage.clear(); }); + + test('nutriscore slider is hidden until the filter switch is enabled', async () => { + // Arrange + const user = userEvent.setup(); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + + // Assert + const nutriscoreSwitch = screen.getByLabelText('nutrition.filterNutriscore'); + expect(nutriscoreSwitch).not.toBeChecked(); + expect(screen.queryByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).not.toBeInTheDocument(); + + await user.click(nutriscoreSwitch); + + expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).toBeInTheDocument(); + }); + + test('nutriscore filter settings are saved to local storage', async () => { + // Arrange + const user = userEvent.setup(); + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + await user.click(screen.getByLabelText('nutrition.filterNutriscore')); + + // Assert + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); + + // Moving the slider with the keyboard changes the selected grade and persists it + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' }); + slider.focus(); + await user.keyboard('{ArrowRight}'); + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX, 'd'); + + setItemSpy.mockRestore(); + }); + + test('nutriscore filter state is loaded from local storage', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + + // Assert + expect(screen.getByLabelText('nutrition.filterNutriscore')).toBeChecked(); + expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).toHaveAttribute('aria-valuetext', 'B'); + localStorage.clear(); + }); + + test('searchIngredient is called with nutriscoreMax when the filter is enabled', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); + render(); + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + + // Act + await user.click(autocomplete); + await user.type(input, 'Yog'); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert + expect(searchIngredient).toHaveBeenCalledWith( + 'Yog', + expect.objectContaining({ nutriscoreMax: 'b' }), + ); + localStorage.clear(); + }); + + test('nutriscoreMax is omitted when the filter switch is off', async () => { + // Arrange + const user = userEvent.setup(); + render(); + const autocomplete = screen.getByTestId('autocomplete'); + const input = within(autocomplete).getByRole('combobox'); + + // Act + await user.click(autocomplete); + await user.type(input, 'Yog'); + await act(async () => { + await new Promise((r) => setTimeout(r, 250)); + }); + + // Assert + expect(searchIngredient).toHaveBeenCalledWith( + 'Yog', + expect.objectContaining({ nutriscoreMax: undefined }), + ); + }); }); diff --git a/src/services/ingredient.ts b/src/services/ingredient.ts index 88e756dc..ffdb416b 100644 --- a/src/services/ingredient.ts +++ b/src/services/ingredient.ts @@ -1,13 +1,22 @@ import axios from 'axios'; import { Ingredient } from "components/Nutrition/models/Ingredient"; import { memoize } from "lodash"; -import { ApiIngredientType } from 'types'; +import { ApiIngredientType, NutriScoreValue } from 'types'; import { API_RESULTS_PAGE_SIZE, ApiPath, LANGUAGE_SHORT_ENGLISH } from "utils/consts"; import { fetchPaginated } from "utils/requests"; import { makeHeader, makeUrl } from "utils/url"; export type IngredientLanguageFilter = "current" | "current_english" | "all"; +export interface IngredientSearchFilters { + languageCode: string; + languageFilter?: IngredientLanguageFilter; + isVegan?: boolean; + isVegetarian?: boolean; + /** Worst acceptable Nutri-Score grade; sent as `nutriscore__lte`. */ + nutriscoreMax?: NutriScoreValue; +} + /* * Memoized version of getIngredient. This caches results in memory for the duration @@ -47,11 +56,16 @@ export const getIngredients = async (ids: number[]): Promise => { export const searchIngredient = async ( name: string, - languageCode: string, - languageFilter: IngredientLanguageFilter = "current_english", - isVegan?: boolean, - isVegetarian?: boolean, + filters: IngredientSearchFilters, ): Promise => { + const { + languageCode, + languageFilter = "current_english", + isVegan, + isVegetarian, + nutriscoreMax, + } = filters; + const languages = languageFilter === "all" ? null : [languageCode]; if (languages && languageFilter === "current_english" && languageCode !== LANGUAGE_SHORT_ENGLISH) { languages.push(LANGUAGE_SHORT_ENGLISH); @@ -70,9 +84,12 @@ export const searchIngredient = async ( if (isVegetarian !== undefined) { query['is_vegetarian'] = String(isVegetarian); } + if (nutriscoreMax !== undefined) { + query['nutriscore__lte'] = nutriscoreMax; + } const url = makeUrl(ApiPath.INGREDIENTINFO_PATH, { query }); const { data } = await axios.get(url, { headers: makeHeader() },); return data.results.map((entry: ApiIngredientType) => Ingredient.fromJson(entry)); -}; \ No newline at end of file +}; From be06e7cc6df79dcc997598b930e31f1df648324f Mon Sep 17 00:00:00 2001 From: kiranannadatha8 Date: Mon, 20 Apr 2026 09:38:07 -0400 Subject: [PATCH 2/2] refactor(nutrition): collapse Nutri-Score toggle into single slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #1237 — the Switch + conditional Slider combination expressed one semantic decision ("what grade ceiling?") through two controls. Folded "Off" into the slider itself: - Slider stops are now Off | A | B | C | D | E (was A..E, gated by Switch). - Off position sends no `nutriscore__lte`; A..E map as before. - Heading changed from "Worst acceptable Nutri-Score" to "Nutri-Score filter", with a dynamic helper below ("No filter" / " or better") that mirrors aria-valuetext for screen-reader parity. - Storage collapsed: removed STORAGE_KEY_NUTRISCORE_ENABLED; STORAGE_KEY_NUTRISCORE_MAX is absent when the slider is at Off. Tests updated: 14/14 pass. --- public/locales/en/translation.json | 6 +- .../widgets/IngredientAutcompleter.tsx | 92 ++++++++++--------- .../widgets/IngredientAutocompleter.test.tsx | 60 ++++++------ 3 files changed, 84 insertions(+), 74 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 67517954..338c6097 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -140,8 +140,10 @@ "languageFilterAll": "All languages", "filterVegan": "Vegan", "filterVegetarian": "Vegetarian", - "filterNutriscore": "Filter by Nutri-Score", - "filterNutriscoreMax": "Worst acceptable grade", + "filterNutriscore": "Nutri-Score filter", + "filterNutriscoreOff": "Off", + "filterNutriscoreNoFilter": "No filter", + "filterNutriscoreOrBetter": "{{grade}} or better", "macronutrient": "Macronutrient", "percentEnergy": "Percent of energy", "gPerBodyKg": "g per body-kg", diff --git a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx index d48515c7..5e0561d2 100644 --- a/src/components/Nutrition/widgets/IngredientAutcompleter.tsx +++ b/src/components/Nutrition/widgets/IngredientAutcompleter.tsx @@ -42,12 +42,19 @@ type IngredientAutocompleterProps = { export const STORAGE_KEY_LANGUAGE_FILTER = "wger.ingredientSearch.languageFilter"; export const STORAGE_KEY_VEGAN = "wger.ingredientSearch.filterVegan"; export const STORAGE_KEY_VEGETARIAN = "wger.ingredientSearch.filterVegetarian"; -export const STORAGE_KEY_NUTRISCORE_ENABLED = "wger.ingredientSearch.filterNutriscoreEnabled"; export const STORAGE_KEY_NUTRISCORE_MAX = "wger.ingredientSearch.filterNutriscoreMax"; +const NUTRISCORE_OFF_INDEX = 0; + const isNutriScoreValue = (value: string | null): value is NutriScoreValue => value !== null && (NUTRI_SCORES as readonly string[]).includes(value); +const sliderIndexToNutriscore = (index: number): NutriScoreValue | null => + index === NUTRISCORE_OFF_INDEX ? null : NUTRI_SCORES[index - 1]; + +const nutriscoreToSliderIndex = (value: NutriScoreValue | null): number => + value === null ? NUTRISCORE_OFF_INDEX : NUTRI_SCORES.indexOf(value) + 1; + export function IngredientAutocompleter({ callback, initialIngredient }: IngredientAutocompleterProps) { const initialData = initialIngredient ?? null; const [t, i18n] = useTranslation(); @@ -65,12 +72,9 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi const [filterVegetarian, setFilterVegetarian] = useState(() => { return localStorage.getItem(STORAGE_KEY_VEGETARIAN) === "true"; }); - const [filterNutriscoreEnabled, setFilterNutriscoreEnabled] = useState(() => { - return localStorage.getItem(STORAGE_KEY_NUTRISCORE_ENABLED) === "true"; - }); - const [nutriscoreMax, setNutriscoreMax] = useState(() => { + const [nutriscoreMax, setNutriscoreMax] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY_NUTRISCORE_MAX); - return isNutriScoreValue(stored) ? stored : "c"; + return isNutriScoreValue(stored) ? stored : null; }); const [filtersAnchorEl, setFiltersAnchorEl] = useState(null); const [value, setValue] = useState(initialData); @@ -96,11 +100,11 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi }, [filterVegetarian]); useEffect(() => { - localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, String(filterNutriscoreEnabled)); - }, [filterNutriscoreEnabled]); - - useEffect(() => { - localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, nutriscoreMax); + if (nutriscoreMax === null) { + localStorage.removeItem(STORAGE_KEY_NUTRISCORE_MAX); + } else { + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, nutriscoreMax); + } }, [nutriscoreMax]); const languageOptions = useMemo(() => { @@ -135,11 +139,11 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi languageFilter, isVegan: filterVegan || undefined, isVegetarian: filterVegetarian || undefined, - nutriscoreMax: filterNutriscoreEnabled ? nutriscoreMax : undefined, + nutriscoreMax: nutriscoreMax ?? undefined, }).then((res) => setOptions(res)), 200 ), - [i18n.language, languageFilter, filterVegan, filterVegetarian, filterNutriscoreEnabled, nutriscoreMax] + [i18n.language, languageFilter, filterVegan, filterVegetarian, nutriscoreMax] ); useEffect(() => { @@ -321,40 +325,38 @@ export function IngredientAutocompleter({ callback, initialIngredient }: Ingredi /> - - setFilterNutriscoreEnabled(checked)} - /> + + + {t("nutrition.filterNutriscore")} + + + {nutriscoreMax === null + ? t("nutrition.filterNutriscoreNoFilter") + : t("nutrition.filterNutriscoreOrBetter", { grade: nutriscoreMax.toUpperCase() })} + + + setNutriscoreMax(sliderIndexToNutriscore(value as number)) } - label={t("nutrition.filterNutriscore")} - /> - - - {filterNutriscoreEnabled && ( - - - {t("nutrition.filterNutriscoreMax")} - - - setNutriscoreMax(NUTRI_SCORES[value as number]) - } - getAriaValueText={(value) => NUTRI_SCORES[value].toUpperCase()} - step={1} - min={0} - max={NUTRI_SCORES.length - 1} - marks={NUTRI_SCORES.map((score, index) => ({ - value: index, + getAriaValueText={(value) => + value === NUTRISCORE_OFF_INDEX + ? t("nutrition.filterNutriscoreNoFilter") + : t("nutrition.filterNutriscoreOrBetter", { grade: NUTRI_SCORES[value - 1].toUpperCase() }) + } + step={1} + min={0} + max={NUTRI_SCORES.length} + marks={[ + { value: NUTRISCORE_OFF_INDEX, label: t("nutrition.filterNutriscoreOff") }, + ...NUTRI_SCORES.map((score, index) => ({ + value: index + 1, label: score.toUpperCase(), - }))} - /> - - )} + })), + ]} + /> + diff --git a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx index a42f0575..d187dddb 100644 --- a/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx +++ b/src/components/Nutrition/widgets/IngredientAutocompleter.test.tsx @@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event"; import { IngredientAutocompleter, STORAGE_KEY_LANGUAGE_FILTER, - STORAGE_KEY_NUTRISCORE_ENABLED, STORAGE_KEY_NUTRISCORE_MAX, STORAGE_KEY_VEGAN, STORAGE_KEY_VEGETARIAN @@ -190,7 +189,7 @@ describe("Test the IngredientAutocompleter component", () => { localStorage.clear(); }); - test('nutriscore slider is hidden until the filter switch is enabled', async () => { + test('nutriscore slider defaults to Off and is always visible in the popover', async () => { // Arrange const user = userEvent.setup(); render(); @@ -198,17 +197,14 @@ describe("Test the IngredientAutocompleter component", () => { // Act await user.click(screen.getByLabelText('Toggle filters')); - // Assert - const nutriscoreSwitch = screen.getByLabelText('nutrition.filterNutriscore'); - expect(nutriscoreSwitch).not.toBeChecked(); - expect(screen.queryByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).not.toBeInTheDocument(); - - await user.click(nutriscoreSwitch); - - expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).toBeInTheDocument(); + // Assert — slider rendered with Off position (index 0) on first load + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); + expect(slider).toBeInTheDocument(); + expect(slider).toHaveAttribute('aria-valuetext', 'nutrition.filterNutriscoreNoFilter'); + expect(slider).toHaveAttribute('aria-valuenow', '0'); }); - test('nutriscore filter settings are saved to local storage', async () => { + test('moving the slider persists the selected grade to local storage', async () => { // Arrange const user = userEvent.setup(); const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); @@ -216,24 +212,38 @@ describe("Test the IngredientAutocompleter component", () => { // Act await user.click(screen.getByLabelText('Toggle filters')); - await user.click(screen.getByLabelText('nutrition.filterNutriscore')); - - // Assert - expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); - - // Moving the slider with the keyboard changes the selected grade and persists it - const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' }); + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); slider.focus(); await user.keyboard('{ArrowRight}'); - expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX, 'd'); + + // Assert — index 1 -> 'a' + expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX, 'a'); setItemSpy.mockRestore(); }); + test('moving the slider back to Off removes the stored grade', async () => { + // Arrange + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'a'); + const removeItemSpy = jest.spyOn(Storage.prototype, 'removeItem'); + render(); + + // Act + await user.click(screen.getByLabelText('Toggle filters')); + const slider = screen.getByRole('slider', { name: 'nutrition.filterNutriscore' }); + slider.focus(); + await user.keyboard('{ArrowLeft}'); + + // Assert + expect(removeItemSpy).toHaveBeenCalledWith(STORAGE_KEY_NUTRISCORE_MAX); + + removeItemSpy.mockRestore(); + }); + test('nutriscore filter state is loaded from local storage', async () => { // Arrange const user = userEvent.setup(); - localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); render(); @@ -241,15 +251,12 @@ describe("Test the IngredientAutocompleter component", () => { await user.click(screen.getByLabelText('Toggle filters')); // Assert - expect(screen.getByLabelText('nutrition.filterNutriscore')).toBeChecked(); - expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscoreMax' })).toHaveAttribute('aria-valuetext', 'B'); - localStorage.clear(); + expect(screen.getByRole('slider', { name: 'nutrition.filterNutriscore' })).toHaveAttribute('aria-valuetext', 'nutrition.filterNutriscoreOrBetter'); }); - test('searchIngredient is called with nutriscoreMax when the filter is enabled', async () => { + test('searchIngredient is called with nutriscoreMax when a grade is selected', async () => { // Arrange const user = userEvent.setup(); - localStorage.setItem(STORAGE_KEY_NUTRISCORE_ENABLED, 'true'); localStorage.setItem(STORAGE_KEY_NUTRISCORE_MAX, 'b'); render(); const autocomplete = screen.getByTestId('autocomplete'); @@ -267,10 +274,9 @@ describe("Test the IngredientAutocompleter component", () => { 'Yog', expect.objectContaining({ nutriscoreMax: 'b' }), ); - localStorage.clear(); }); - test('nutriscoreMax is omitted when the filter switch is off', async () => { + test('nutriscoreMax is omitted when the slider is at Off', async () => { // Arrange const user = userEvent.setup(); render();