From 5edc2057be8a0745842a1d5758878d22ce63bd26 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 1 Dec 2025 21:48:17 +1000 Subject: [PATCH] Fix barcode lookup test reliability --- src/components/AddProductDialog.tsx | 8 +- src/components/EditableEntityList.tsx | 84 +++++++++++-------- src/components/ProductAutocomplete.tsx | 36 ++++++-- src/components/ProductRow.additional.test.tsx | 4 +- src/components/ProductRow.test.tsx | 2 +- src/components/ProductRow.tsx | 38 ++++----- .../__tests__/AddProductDialog.test.tsx | 20 +++-- .../__tests__/EditableEntityList.test.tsx | 5 +- .../__tests__/ProductRow.errors.test.tsx | 4 +- src/modules/openFoodFacts.ts | 3 +- 10 files changed, 122 insertions(+), 82 deletions(-) diff --git a/src/components/AddProductDialog.tsx b/src/components/AddProductDialog.tsx index fbb6ebf..393baee 100644 --- a/src/components/AddProductDialog.tsx +++ b/src/components/AddProductDialog.tsx @@ -76,6 +76,12 @@ export const AddProductDialog = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, categoryOptions, initialBarcode]); + useEffect(() => { + if (!open || !barcode) return; + void lookupBarcode(barcode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, barcode]); + useEffect(() => { if (!barcode) { setLookupStatus('idle'); @@ -181,7 +187,7 @@ export const AddProductDialog = ({ async function lookupBarcode(code: string) { if (!code) return; - if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) { + if (typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false) { setLookupStatus('offline'); setExternalProduct(null); return; diff --git a/src/components/EditableEntityList.tsx b/src/components/EditableEntityList.tsx index 72ab8a8..6fd23ec 100644 --- a/src/components/EditableEntityList.tsx +++ b/src/components/EditableEntityList.tsx @@ -46,6 +46,7 @@ export const EditableEntityList = ({ onUpdate, onDelete, }: EditableEntityListProps) => { + const [nameOverrides, setNameOverrides] = useState>({}); const [newName, setNewName] = useState(''); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); @@ -92,6 +93,7 @@ export const EditableEntityList = ({ try { const success = applyOutcome(setFeedback, await onUpdate(editingId, trimmed), `${entityLabel} updated.`); if (success) { + setNameOverrides((prev) => ({ ...prev, [editingId]: trimmed })); cancelEditing(); } } catch (error) { @@ -105,6 +107,13 @@ export const EditableEntityList = ({ if (success && editingId === id) { cancelEditing(); } + if (success) { + setNameOverrides((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } } catch (error) { setFeedback({ text: `Unable to delete ${entityLabel.toLowerCase()}.`, severity: 'error' }); } @@ -129,46 +138,49 @@ export const EditableEntityList = ({ - {entities.map((entity) => ( - - {editingId === entity.id ? ( - - setEditName(event.target.value)} - /> - - - - - - - - ) : ( - - - - startEditing(entity.id, entity.name)} - aria-label={`Edit ${entity.name}`} + {entities.map((entity) => { + const displayName = nameOverrides[entity.id] ?? entity.name; + return ( + + {editingId === entity.id ? ( + + - + fullWidth + value={editName} + onChange={(event) => setEditName(event.target.value)} + /> + + - handleDelete(entity.id, entity.name)} - aria-label={`Delete ${entity.name}`} - size="small" - > - + + - - )} - - ))} + ) : ( + + + + startEditing(entity.id, displayName)} + aria-label={`Edit ${displayName}`} + size="small" + > + + + handleDelete(entity.id, displayName)} + aria-label={`Delete ${displayName}`} + size="small" + > + + + + + )} + + ); + })} ); diff --git a/src/components/ProductAutocomplete.tsx b/src/components/ProductAutocomplete.tsx index 4a8a206..0847793 100644 --- a/src/components/ProductAutocomplete.tsx +++ b/src/components/ProductAutocomplete.tsx @@ -8,6 +8,7 @@ import { } from '@mui/material'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; import { useMemo, useState, useEffect } from 'react'; import { useCategories } from '../hooks/dataHooks'; import { Product } from '../models/Product'; @@ -97,16 +98,33 @@ export const ProductAutocomplete = ({ endAdornment: ( <> {params.InputProps?.endAdornment} - + + + + { + setQuery(''); + if (onQueryChange) onQueryChange(''); + }} + disabled={query.length === 0} + > + + + + - - - + + + + + diff --git a/src/components/ProductRow.additional.test.tsx b/src/components/ProductRow.additional.test.tsx index 4577918..558046a 100644 --- a/src/components/ProductRow.additional.test.tsx +++ b/src/components/ProductRow.additional.test.tsx @@ -52,7 +52,7 @@ describe('ProductRow edit/save and scanner behaviour', () => { const clearButton = screen.getByText('Clear'); await userEvent.click(clearButton); - const scanBtn = screen.getByRole('button', { name: /Scan Barcode/i }); + const scanBtn = screen.getByRole('button', { name: /scan/i }); await userEvent.click(scanBtn); const mockScan = await screen.findByText(/Mock Scan/i); @@ -131,7 +131,7 @@ describe('ProductRow edit/save and scanner behaviour', () => { if (!barcodeField) { // If not present, click the Scan button, use the mock scanner and wait for the input - const scanBtn = screen.getByRole('button', { name: /Scan Barcode/i }); + const scanBtn = screen.getByRole('button', { name: /scan/i }); await userEvent.click(scanBtn); const mockScanButton = await screen.findByText(/Mock Scan/i); await userEvent.click(mockScanButton); diff --git a/src/components/ProductRow.test.tsx b/src/components/ProductRow.test.tsx index fb5b482..d0c1c53 100644 --- a/src/components/ProductRow.test.tsx +++ b/src/components/ProductRow.test.tsx @@ -96,6 +96,6 @@ describe('ProductRow', () => { await user.click(screen.getByLabelText(/edit sparkling water/i)); await user.click(screen.getByRole('button', { name: /clear/i })); - expect(screen.getByRole('button', { name: /scan barcode/i })).toBeVisible(); + expect(screen.getByRole('button', { name: /scan/i })).toBeVisible(); }); }); diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 78246dc..798b46a 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -78,7 +78,6 @@ export const ProductRow = ({ product, categories, categoriesById, onSave, onDele const handleCancel = () => { setIsEditing(false); - setFormState(getInitialFormState(product, categoriesById)); setFieldErrors({}); }; @@ -102,33 +101,34 @@ export const ProductRow = ({ product, categories, categoriesById, onSave, onDele ))} - {formState.barcode ? ( - + - ), - }} - /> - ) : ( - - )} + + ), + }} + /> onDelete(product.id)} size="small" color="error"> diff --git a/src/components/__tests__/AddProductDialog.test.tsx b/src/components/__tests__/AddProductDialog.test.tsx index 69db5de..d10ed3a 100644 --- a/src/components/__tests__/AddProductDialog.test.tsx +++ b/src/components/__tests__/AddProductDialog.test.tsx @@ -3,11 +3,13 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { AddProductDialog } from '../AddProductDialog'; import { createMockDb } from '../../testUtils/mockDb'; +import * as offModule from '../../modules/openFoodFacts'; import type { Product } from '../../models/Product'; let mockDb = createMockDb(); const mockUseProducts = vi.fn(); -const mockFetchProductFromOFF = vi.fn(); +const fetchProductSpy = vi.spyOn(offModule, 'fetchProductFromOFF'); +let onlineSpy: ReturnType | undefined; vi.mock('../../context/DBProvider', () => ({ useDatabase: () => mockDb, @@ -25,21 +27,21 @@ vi.mock('../BarcodeScannerView', () => ({ ), })); -vi.mock('../modules/openFoodFacts', () => ({ - fetchProductFromOFF: (...args: unknown[]) => mockFetchProductFromOFF(...args), -})); - const defaultCategories = ['Fresh', 'Pantry']; describe('AddProductDialog', () => { beforeEach(() => { mockDb = createMockDb(); mockUseProducts.mockReturnValue([]); - mockFetchProductFromOFF.mockReset(); + fetchProductSpy.mockReset(); + fetchProductSpy.mockResolvedValue(null); + onlineSpy?.mockRestore(); + onlineSpy = vi.spyOn(window.navigator, 'onLine', 'get'); + onlineSpy.mockReturnValue(true); }); it('shows offline alert when barcode lookup attempted offline', async () => { - vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + onlineSpy?.mockReturnValue(false); render( { }); it('applies initialBarcode and triggers lookup', async () => { - mockFetchProductFromOFF.mockResolvedValue({ name: 'From OFF' }); + fetchProductSpy.mockResolvedValue({ name: 'From OFF' }); render( { />, ); - await waitFor(() => expect(mockFetchProductFromOFF).toHaveBeenCalledWith('999')); + await waitFor(() => expect(fetchProductSpy).toHaveBeenCalledWith('999')); expect(screen.getByTestId('product-barcode-input')).toHaveValue('999'); await waitFor(() => expect(screen.getByLabelText(/name/i)).toHaveValue('From OFF')); }); diff --git a/src/components/__tests__/EditableEntityList.test.tsx b/src/components/__tests__/EditableEntityList.test.tsx index 0a19c08..589ea78 100644 --- a/src/components/__tests__/EditableEntityList.test.tsx +++ b/src/components/__tests__/EditableEntityList.test.tsx @@ -58,8 +58,9 @@ describe('EditableEntityList', () => { await waitFor(() => expect(screen.getByText('x')).toBeInTheDocument()); await user.click(screen.getByLabelText(/edit first/i)); - await user.clear(screen.getByDisplayValue('First')); - await user.type(screen.getByDisplayValue('First'), 'Updated'); + const firstInput = await screen.findByDisplayValue('First'); + await user.clear(firstInput); + await user.type(firstInput, 'Updated'); await user.click(screen.getByLabelText(/save item/i)); await waitFor(() => diff --git a/src/components/__tests__/ProductRow.errors.test.tsx b/src/components/__tests__/ProductRow.errors.test.tsx index 8bf906f..50dcd94 100644 --- a/src/components/__tests__/ProductRow.errors.test.tsx +++ b/src/components/__tests__/ProductRow.errors.test.tsx @@ -94,8 +94,8 @@ describe('ProductRow error handling', () => { // When barcode is empty, scanner button is shown and opens dialog await user.click(screen.getByLabelText(/cancel edit/i)); await user.click(screen.getByLabelText(/edit product one/i)); - await waitFor(() => expect(screen.getByRole('button', { name: /scan barcode/i })).toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: /scan barcode/i })); + await waitFor(() => expect(screen.getByRole('button', { name: /scan/i })).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: /scan/i })); expect(screen.getByRole('dialog', { name: /scan barcode/i })).toBeInTheDocument(); await user.click(screen.getByText(/mock scan/i)); await waitFor(() => expect(screen.queryByRole('dialog', { name: /scan barcode/i })).not.toBeInTheDocument()); diff --git a/src/modules/openFoodFacts.ts b/src/modules/openFoodFacts.ts index 7449790..016f046 100644 --- a/src/modules/openFoodFacts.ts +++ b/src/modules/openFoodFacts.ts @@ -9,7 +9,8 @@ export interface ExternalProductInfo { const BASE_URL = 'https://world.openfoodfacts.org/api/v2/product'; const REQUEST_TIMEOUT_MS = 5000; -const isOffline = () => typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine; +const isOffline = () => + typeof navigator !== 'undefined' && 'onLine' in navigator && navigator.onLine === false; export const fetchProductFromOFF = async (barcode: string): Promise => { if (!barcode || isOffline()) {