MORE UNIT TESTS - approc 94% before - 96% now?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 53m9s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 53m9s
This commit is contained in:
@@ -194,21 +194,42 @@ describe('FlyerCorrectionTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if rescan is attempted without a selection', async () => {
|
||||
it('should show an error if rescan is attempted before image is loaded', async () => {
|
||||
console.log('TEST: Starting "should show an error if rescan is attempted before image is loaded"');
|
||||
|
||||
// Override fetch to be pending forever so 'imageFile' remains null
|
||||
// This allows us to test the guard clause inside handleRescan while the button is enabled
|
||||
global.fetch = vi.fn(() => {
|
||||
console.log('TEST: fetch called, returning pending promise to simulate loading');
|
||||
return new Promise(() => {});
|
||||
}) as Mocked<typeof fetch>;
|
||||
|
||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
||||
|
||||
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
|
||||
|
||||
// Draw a selection to enable the button (bypassing the disabled={!selectionRect} check)
|
||||
console.log('TEST: Drawing selection to enable button');
|
||||
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
|
||||
fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 });
|
||||
fireEvent.mouseUp(canvas);
|
||||
|
||||
const extractButton = screen.getByRole('button', { name: /extract store name/i });
|
||||
expect(extractButton).toBeEnabled();
|
||||
console.log('TEST: Button is enabled, clicking now...');
|
||||
|
||||
expect(extractButton).toBeDisabled();
|
||||
|
||||
// To properly test the guard, we need to bypass the disabled state
|
||||
// We force the button to be enabled to trigger the click handler
|
||||
extractButton.removeAttribute('disabled');
|
||||
// Attempt rescan.
|
||||
// - selectionRect is present (button enabled)
|
||||
// - imageFile is null (fetch pending)
|
||||
// -> Should trigger guard and notifyError
|
||||
fireEvent.click(extractButton);
|
||||
|
||||
console.log('TEST: Checking for error notification');
|
||||
expect(mockedNotifyError).toHaveBeenCalledWith('Please select an area on the image first.');
|
||||
});
|
||||
|
||||
it('should handle non-standard API errors during rescan', async () => {
|
||||
console.log('TEST: Starting "should handle non-standard API errors during rescan"');
|
||||
mockedAiApiClient.rescanImageArea.mockRejectedValue('A plain string error');
|
||||
render(<FlyerCorrectionTool {...defaultProps} />);
|
||||
|
||||
@@ -224,6 +245,7 @@ describe('FlyerCorrectionTool', () => {
|
||||
fireEvent.mouseMove(canvas, { clientX: 100, clientY: 50 });
|
||||
fireEvent.mouseUp(canvas);
|
||||
|
||||
console.log('TEST: Clicking button to trigger API error');
|
||||
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockedNotifyError).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
|
||||
@@ -109,12 +109,20 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
|
||||
};
|
||||
|
||||
const handleRescan = async (type: ExtractionType) => {
|
||||
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
|
||||
console.debug(`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`);
|
||||
|
||||
if (!selectionRect || !imageRef.current || !imageFile) {
|
||||
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
|
||||
if (!selectionRect) console.warn('[DEBUG] Reason: No selectionRect');
|
||||
if (!imageRef.current) console.warn('[DEBUG] Reason: No imageRef');
|
||||
if (!imageFile) console.warn('[DEBUG] Reason: No imageFile');
|
||||
|
||||
notifyError('Please select an area on the image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`[DEBUG] handleRescan: Starting for type "${type}". Setting isProcessing=true.`);
|
||||
console.debug(`[DEBUG] handleRescan: Prerequisites met. Starting processing for "${type}".`);
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Scale selection coordinates to the original image dimensions
|
||||
|
||||
@@ -20,7 +20,8 @@ vi.mock('../services/notificationService', () => ({
|
||||
|
||||
describe('useApi Hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
console.log('--- Test Setup: Resetting Mocks ---');
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should initialize with correct default states', () => {
|
||||
@@ -94,20 +95,49 @@ describe('useApi Hook', () => {
|
||||
});
|
||||
|
||||
it('should clear previous error when execute is called again', async () => {
|
||||
console.log('Test: should clear previous error when execute is called again');
|
||||
mockApiFunction.mockRejectedValueOnce(new Error('Fail'));
|
||||
mockApiFunction.mockResolvedValueOnce(new Response(JSON.stringify({ success: true })));
|
||||
|
||||
// We use a controlled promise for the second call to assert state while it is pending
|
||||
let resolveSecondCall: (value: Response) => void;
|
||||
const secondCallPromise = new Promise<Response>((resolve) => {
|
||||
resolveSecondCall = resolve;
|
||||
});
|
||||
mockApiFunction.mockReturnValueOnce(secondCallPromise);
|
||||
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
// First call fails
|
||||
await act(async () => { await result.current.execute(); });
|
||||
console.log('Step: Executing first call (expected failure)');
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.execute();
|
||||
} catch {
|
||||
// We expect this to fail
|
||||
}
|
||||
});
|
||||
console.log('Step: First call finished. Error state:', result.current.error);
|
||||
expect(result.current.error).not.toBeNull();
|
||||
|
||||
// Second call starts
|
||||
const promise = act(async () => { await result.current.execute(); });
|
||||
let executePromise: Promise<any>;
|
||||
console.log('Step: Starting second call');
|
||||
act(() => {
|
||||
executePromise = result.current.execute();
|
||||
});
|
||||
|
||||
// Error should be cleared immediately upon execution start
|
||||
console.log('Step: Second call started. Error state (should be null):', result.current.error);
|
||||
expect(result.current.error).toBeNull();
|
||||
await promise;
|
||||
|
||||
// Resolve the second call
|
||||
console.log('Step: Resolving second call promise');
|
||||
resolveSecondCall!(new Response(JSON.stringify({ success: true })));
|
||||
|
||||
await act(async () => {
|
||||
await executePromise;
|
||||
});
|
||||
console.log('Step: Second call finished');
|
||||
});
|
||||
|
||||
it('should handle 204 No Content responses correctly', async () => {
|
||||
@@ -153,62 +183,93 @@ describe('useApi Hook', () => {
|
||||
|
||||
describe('isRefetching state', () => {
|
||||
it('should set isRefetching to true on second call, but not first', async () => {
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify({ data: 'first call' })));
|
||||
// Provide the generic type argument to useApi
|
||||
console.log('Test: isRefetching state - success path');
|
||||
// First call setup
|
||||
let resolveFirst: (val: Response) => void;
|
||||
const firstPromise = new Promise<Response>((resolve) => { resolveFirst = resolve; });
|
||||
mockApiFunction.mockReturnValueOnce(firstPromise);
|
||||
|
||||
const { result } = renderHook(() => useApi<{ data: string }, []>(mockApiFunction));
|
||||
|
||||
// --- First call ---
|
||||
let firstCallPromise: Promise<{ data: string; } | null>;
|
||||
let firstCallPromise: Promise<any>;
|
||||
console.log('Step: Starting first call');
|
||||
act(() => {
|
||||
firstCallPromise = result.current.execute();
|
||||
});
|
||||
|
||||
// During the first call, loading is true, but isRefetching is false
|
||||
console.log('Check: First call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
|
||||
console.log('Step: Resolving first call');
|
||||
resolveFirst!(new Response(JSON.stringify({ data: 'first call' })));
|
||||
await act(async () => { await firstCallPromise; });
|
||||
|
||||
// After the first call, both are false
|
||||
console.log('Check: First call done. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
expect(result.current.data).toEqual({ data: 'first call' });
|
||||
|
||||
// --- Second call ---
|
||||
mockApiFunction.mockResolvedValue(new Response(JSON.stringify({ data: 'second call' })));
|
||||
let secondCallPromise: Promise<{ data: string; } | null>;
|
||||
let resolveSecond: (val: Response) => void;
|
||||
const secondPromise = new Promise<Response>((resolve) => { resolveSecond = resolve; });
|
||||
mockApiFunction.mockReturnValueOnce(secondPromise);
|
||||
|
||||
let secondCallPromise: Promise<any>;
|
||||
console.log('Step: Starting second call');
|
||||
act(() => {
|
||||
secondCallPromise = result.current.execute();
|
||||
});
|
||||
|
||||
// During the second call, both loading and isRefetching are true
|
||||
console.log('Check: Second call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(true);
|
||||
|
||||
console.log('Step: Resolving second call');
|
||||
resolveSecond!(new Response(JSON.stringify({ data: 'second call' })));
|
||||
await act(async () => { await secondCallPromise; });
|
||||
|
||||
// After the second call, both are false again
|
||||
console.log('Check: Second call done. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
expect(result.current.data).toEqual({ data: 'second call' });
|
||||
});
|
||||
|
||||
it('should not set isRefetching to true if the first call failed', async () => {
|
||||
console.log('Test: isRefetching state - failure path');
|
||||
// First call fails
|
||||
mockApiFunction.mockRejectedValueOnce(new Error('Fail'));
|
||||
const { result } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
await act(async () => { await result.current.execute(); });
|
||||
console.log('Step: Executing first call (fail)');
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.execute();
|
||||
} catch {}
|
||||
});
|
||||
expect(result.current.error).not.toBeNull();
|
||||
|
||||
// Second call succeeds
|
||||
mockApiFunction.mockResolvedValueOnce(new Response(JSON.stringify({ data: 'success' })));
|
||||
let resolveSecond: (val: Response) => void;
|
||||
const secondPromise = new Promise<Response>((resolve) => { resolveSecond = resolve; });
|
||||
mockApiFunction.mockReturnValueOnce(secondPromise);
|
||||
|
||||
let secondCallPromise: Promise<any>;
|
||||
console.log('Step: Starting second call');
|
||||
act(() => { secondCallPromise = result.current.execute(); });
|
||||
|
||||
// Should still be loading (initial load behavior) because first load never succeeded
|
||||
console.log('Check: Second call in flight. loading:', result.current.loading, 'isRefetching:', result.current.isRefetching);
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.isRefetching).toBe(false);
|
||||
|
||||
console.log('Step: Resolving second call');
|
||||
resolveSecond!(new Response(JSON.stringify({ data: 'success' })));
|
||||
await act(async () => { await secondCallPromise; });
|
||||
});
|
||||
});
|
||||
@@ -292,16 +353,17 @@ describe('useApi Hook', () => {
|
||||
|
||||
describe('Request Cancellation', () => {
|
||||
it('should not set an error state if the request is aborted on unmount', async () => {
|
||||
console.log('Test: Request Cancellation');
|
||||
// Create a promise that we can control from outside
|
||||
let resolvePromise: (value: Response) => void;
|
||||
const controlledPromise = new Promise<Response>(resolve => {
|
||||
resolvePromise = resolve;
|
||||
const controlledPromise = new Promise<Response>(() => {
|
||||
// Never resolve
|
||||
});
|
||||
mockApiFunction.mockImplementation(() => controlledPromise);
|
||||
mockApiFunction.mockReturnValue(controlledPromise);
|
||||
|
||||
const { result, unmount } = renderHook(() => useApi(mockApiFunction));
|
||||
|
||||
// Start the API call
|
||||
console.log('Step: Executing call');
|
||||
act(() => {
|
||||
result.current.execute();
|
||||
});
|
||||
@@ -310,9 +372,11 @@ describe('useApi Hook', () => {
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
// Unmount the component, which should trigger the AbortController
|
||||
console.log('Step: Unmounting');
|
||||
unmount();
|
||||
|
||||
// The error should be null because the AbortError is caught and ignored
|
||||
console.log('Check: Error state after unmount:', result.current.error);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ export function useApi<T, TArgs extends unknown[]>(
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (...args: TArgs): Promise<T | null> => {
|
||||
logger.info('useApi.execute called', { functionName: apiFunction.name });
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (hasBeenExecuted.current) {
|
||||
|
||||
@@ -291,23 +291,42 @@ describe('useShoppingLists Hook', () => {
|
||||
});
|
||||
|
||||
it('should set activeListId to null when the last list is deleted', async () => {
|
||||
console.log('TEST: should set activeListId to null when the last list is deleted');
|
||||
const singleList: ShoppingList[] = [{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] }];
|
||||
mockedUseUserData.mockReturnValue({ shoppingLists: singleList, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null });
|
||||
mockDeleteListApi.mockResolvedValue(null);
|
||||
let currentLists = singleList;
|
||||
|
||||
const { result } = renderHook(() => useShoppingLists());
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteList(1);
|
||||
// Mock the setter to update our local state variable and trigger a rerender
|
||||
(mockSetShoppingLists as Mock).mockImplementation((updater) => {
|
||||
console.log(' LOG: mockSetShoppingLists called. Updating currentLists.');
|
||||
currentLists = typeof updater === 'function' ? updater(currentLists) : updater;
|
||||
console.log(' LOG: currentLists is now:', JSON.stringify(currentLists));
|
||||
});
|
||||
|
||||
// The hook's internal logic will set the active list to null
|
||||
// We also need to check that the global state setter was called to empty the list
|
||||
await waitFor(() => expect(mockSetShoppingLists).toHaveBeenCalledWith([]));
|
||||
// Mock useUserData to read from our managed state
|
||||
mockedUseUserData.mockImplementation(() => ({
|
||||
shoppingLists: currentLists,
|
||||
setShoppingLists: mockSetShoppingLists,
|
||||
watchedItems: [],
|
||||
setWatchedItems: vi.fn(),
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
// After the list is empty, the effect will run and set activeListId to null
|
||||
mockDeleteListApi.mockResolvedValue(null);
|
||||
|
||||
const { result, rerender } = renderHook(() => useShoppingLists());
|
||||
console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId);
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
console.log(' LOG: ActiveListId successfully set to 1.');
|
||||
await act(async () => {
|
||||
console.log(' LOG: Calling deleteList(1).');
|
||||
await result.current.deleteList(1);
|
||||
console.log(' LOG: deleteList(1) finished. Rerendering component with updated lists.');
|
||||
rerender();
|
||||
});
|
||||
console.log(' LOG: act/rerender complete. Final ActiveListId:', result.current.activeListId);
|
||||
await waitFor(() => expect(result.current.activeListId).toBeNull());
|
||||
console.log(' LOG: SUCCESS! ActiveListId is null as expected.');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -386,14 +405,22 @@ describe('useShoppingLists Hook', () => {
|
||||
});
|
||||
|
||||
it('should not call update API if no list is active', async () => {
|
||||
console.log('TEST: should not call update API if no list is active');
|
||||
const { result } = renderHook(() => useShoppingLists());
|
||||
act(() => { result.current.setActiveListId(null); }); // Ensure no active list
|
||||
console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId);
|
||||
|
||||
// Wait for the initial effect to set the active list
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
console.log(' LOG: Initial active list is 1.');
|
||||
|
||||
act(() => { result.current.setActiveListId(null); }); // Ensure no active list
|
||||
console.log(' LOG: Manually set activeListId to null. Current value:', result.current.activeListId);
|
||||
await act(async () => {
|
||||
console.log(' LOG: Calling updateItemInList while activeListId is null.');
|
||||
await result.current.updateItemInList(101, { is_purchased: true });
|
||||
});
|
||||
|
||||
expect(mockUpdateItemApi).not.toHaveBeenCalled();
|
||||
console.log(' LOG: SUCCESS! mockUpdateItemApi was not called.');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -429,14 +456,22 @@ describe('useShoppingLists Hook', () => {
|
||||
});
|
||||
|
||||
it('should not call remove API if no list is active', async () => {
|
||||
console.log('TEST: should not call remove API if no list is active');
|
||||
const { result } = renderHook(() => useShoppingLists());
|
||||
act(() => { result.current.setActiveListId(null); }); // Ensure no active list
|
||||
console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId);
|
||||
|
||||
// Wait for the initial effect to set the active list
|
||||
await waitFor(() => expect(result.current.activeListId).toBe(1));
|
||||
console.log(' LOG: Initial active list is 1.');
|
||||
|
||||
act(() => { result.current.setActiveListId(null); }); // Ensure no active list
|
||||
console.log(' LOG: Manually set activeListId to null. Current value:', result.current.activeListId);
|
||||
await act(async () => {
|
||||
console.log(' LOG: Calling removeItemFromList while activeListId is null.');
|
||||
await result.current.removeItemFromList(101);
|
||||
});
|
||||
|
||||
expect(mockRemoveItemApi).not.toHaveBeenCalled();
|
||||
console.log(' LOG: SUCCESS! mockRemoveItemApi was not called.');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -42,12 +42,20 @@ const useShoppingListsHook = () => {
|
||||
|
||||
// Effect to select the first list as active when lists are loaded or the user changes.
|
||||
useEffect(() => {
|
||||
if (user && shoppingLists.length > 0 && !shoppingLists.some(l => l.shopping_list_id === activeListId)) {
|
||||
setActiveListId(shoppingLists[0].shopping_list_id);
|
||||
} else if (!user || shoppingLists.length === 0) {
|
||||
// Check if the currently active list still exists in the shoppingLists array.
|
||||
const activeListExists = shoppingLists.some(l => l.shopping_list_id === activeListId);
|
||||
|
||||
// If the user is logged in and there are lists...
|
||||
if (user && shoppingLists.length > 0) {
|
||||
// ...but no list is active, or the active one was deleted, select the first available list.
|
||||
if (!activeListExists) {
|
||||
setActiveListId(shoppingLists[0].shopping_list_id);
|
||||
}
|
||||
} else if (activeListId !== null) {
|
||||
// If there's no user or no lists, ensure no list is active.
|
||||
setActiveListId(null);
|
||||
}
|
||||
}, [shoppingLists, user, activeListId]);
|
||||
}, [shoppingLists, user]); // This effect should NOT depend on activeListId to prevent re-selection loops.
|
||||
|
||||
const createList = useCallback(async (name: string) => {
|
||||
if (!user) return;
|
||||
@@ -68,12 +76,17 @@ const useShoppingListsHook = () => {
|
||||
try {
|
||||
const result = await deleteListApi(listId);
|
||||
// A successful DELETE will have a null result from useApi (for 204 No Content)
|
||||
// We handle the state update optimistically inside the hook.
|
||||
if (result === null) {
|
||||
const newLists = shoppingLists.filter(l => l.shopping_list_id !== listId);
|
||||
setShoppingLists(newLists);
|
||||
if (activeListId === listId) {
|
||||
setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null);
|
||||
}
|
||||
setShoppingLists(prevLists => {
|
||||
const newLists = prevLists.filter(l => l.shopping_list_id !== listId);
|
||||
// If the deleted list was the active one, we need to select a new active list.
|
||||
// If no lists are left, the useEffect will handle setting activeListId to null.
|
||||
if (activeListId === listId && newLists.length > 0) {
|
||||
setActiveListId(newLists[0].shopping_list_id);
|
||||
}
|
||||
return newLists;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('useShoppingLists: Failed to delete list.', e);
|
||||
@@ -85,14 +98,14 @@ const useShoppingListsHook = () => {
|
||||
try {
|
||||
const newItem = await addItemApi(listId, item);
|
||||
if (newItem) {
|
||||
setShoppingLists(prevLists => prevLists.map(list => {
|
||||
if (list.shopping_list_id === listId) {
|
||||
setShoppingLists(prevLists =>
|
||||
prevLists.map(list => {
|
||||
if (list.shopping_list_id === listId) {
|
||||
// Prevent adding a duplicate item if it's a master item and already exists in the list.
|
||||
// We don't prevent duplicates for custom items as they don't have a unique ID.
|
||||
const itemExists = newItem.master_item_id
|
||||
? list.items.some(i => i.master_item_id === newItem.master_item_id)
|
||||
: false;
|
||||
if (itemExists) return list;
|
||||
|
||||
return { ...list, items: [...list.items, newItem] };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import * as aiApiClient from '../services/aiApiClient';
|
||||
import { notifyError } from '../services/notificationService';
|
||||
import { logger } from '../services/logger.client';
|
||||
|
||||
// Extensive logging for debugging
|
||||
const LOG_PREFIX = '[TEST DEBUG]';
|
||||
|
||||
vi.mock('../services/notificationService');
|
||||
|
||||
// 1. Mock the module to replace its exports with mock functions.
|
||||
@@ -24,19 +27,20 @@ vi.mock('../services/logger.client', () => ({
|
||||
|
||||
// Define mock at module level so it can be referenced in the implementation
|
||||
const mockAudioPlay = vi.fn(() => {
|
||||
console.log('[TEST MOCK] mockAudioPlay called');
|
||||
console.log(`${LOG_PREFIX} mockAudioPlay executed`);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
describe('VoiceLabPage', () => {
|
||||
beforeEach(() => {
|
||||
console.log(`${LOG_PREFIX} beforeEach: Cleaning mocks and setting up Audio mock`);
|
||||
vi.clearAllMocks();
|
||||
mockAudioPlay.mockClear();
|
||||
|
||||
// Mock the global Audio constructor
|
||||
// We use a robust mocking strategy to ensure it overrides JSDOM's Audio
|
||||
const AudioMock = vi.fn((url) => {
|
||||
console.log('[TEST MOCK] Audio constructor called with:', url);
|
||||
const AudioMock = vi.fn(function(url: string) {
|
||||
console.log(`${LOG_PREFIX} Audio constructor called with URL:`, url);
|
||||
return {
|
||||
play: mockAudioPlay,
|
||||
pause: vi.fn(),
|
||||
@@ -57,52 +61,63 @@ describe('VoiceLabPage', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log(`${LOG_PREFIX} afterEach: unstubbing globals`);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should render the initial state correctly', () => {
|
||||
console.log(`${LOG_PREFIX} Test: render initial state`);
|
||||
render(<VoiceLabPage />);
|
||||
expect(screen.getByRole('heading', { name: /admin voice lab/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /text-to-speech generation/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('textbox')).toHaveValue('Hello! This is a test of the text-to-speech generation.');
|
||||
expect(screen.getByRole('button', { name: /generate & play/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /replay/i })).not.toBeInTheDocument();
|
||||
console.log(`${LOG_PREFIX} Test: render initial state passed`);
|
||||
});
|
||||
|
||||
describe('Text-to-Speech Generation', () => {
|
||||
it('should call generateSpeechFromText and play audio on success', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: generateSpeechFromText success flow`);
|
||||
const mockBase64Audio = 'mock-audio-data';
|
||||
mockedAiApiClient.generateSpeechFromText.mockResolvedValue({
|
||||
json: async () => mockBase64Audio,
|
||||
json: async () => {
|
||||
console.log(`${LOG_PREFIX} Mock response.json() called`);
|
||||
return mockBase64Audio;
|
||||
},
|
||||
} as Response);
|
||||
|
||||
render(<VoiceLabPage />);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /generate & play/i });
|
||||
console.log(`${LOG_PREFIX} Clicking generate button`);
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Check for loading state
|
||||
expect(generateButton).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Waiting for generateSpeechFromText call`);
|
||||
expect(mockedAiApiClient.generateSpeechFromText).toHaveBeenCalledWith('Hello! This is a test of the text-to-speech generation.');
|
||||
});
|
||||
|
||||
// Wait specifically for the audio constructor call
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Waiting for Audio constructor call`);
|
||||
expect(global.Audio).toHaveBeenCalledWith(`data:audio/mpeg;base64,${mockBase64Audio}`);
|
||||
});
|
||||
|
||||
// Then check play
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Waiting for mockAudioPlay call`);
|
||||
// Debugging helper: if play wasn't called, check if error was notified
|
||||
if (mockAudioPlay.mock.calls.length === 0) {
|
||||
const errorCalls = vi.mocked(notifyError).mock.calls;
|
||||
if (errorCalls.length > 0) {
|
||||
console.error('[TEST DEBUG] notifyError was called:', errorCalls);
|
||||
console.error(`${LOG_PREFIX} Unexpected: notifyError was called during success test:`, errorCalls);
|
||||
} else {
|
||||
// If notifyError wasn't called, verify if Audio constructor was actually called as expected
|
||||
console.log('[TEST DEBUG] Audio constructor call count:', (window.Audio as any).mock?.calls?.length);
|
||||
console.log(`${LOG_PREFIX} mockAudioPlay not called yet...`);
|
||||
}
|
||||
}
|
||||
expect(mockAudioPlay).toHaveBeenCalled();
|
||||
@@ -110,28 +125,35 @@ describe('VoiceLabPage', () => {
|
||||
|
||||
// Check that loading state is gone and replay button is visible
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Waiting for UI update (button enabled, replay visible)`);
|
||||
expect(screen.getByRole('button', { name: /generate & play/i })).not.toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: /replay/i })).toBeInTheDocument();
|
||||
});
|
||||
console.log(`${LOG_PREFIX} Test: generateSpeechFromText success flow passed`);
|
||||
});
|
||||
|
||||
it('should show an error notification if text is empty', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: empty text validation`);
|
||||
render(<VoiceLabPage />);
|
||||
const textarea = screen.getByRole('textbox');
|
||||
fireEvent.change(textarea, { target: { value: ' ' } }); // Empty text
|
||||
|
||||
console.log(`${LOG_PREFIX} Clicking generate button with empty text`);
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate & play/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith('Please enter some text to generate speech.');
|
||||
});
|
||||
expect(mockedAiApiClient.generateSpeechFromText).not.toHaveBeenCalled();
|
||||
console.log(`${LOG_PREFIX} Test: empty text validation passed`);
|
||||
});
|
||||
|
||||
it('should show an error notification if API call fails', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: API failure`);
|
||||
mockedAiApiClient.generateSpeechFromText.mockRejectedValue(new Error('AI service is down'));
|
||||
render(<VoiceLabPage />);
|
||||
|
||||
console.log(`${LOG_PREFIX} Clicking generate button (expecting failure)`);
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate & play/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -141,6 +163,7 @@ describe('VoiceLabPage', () => {
|
||||
});
|
||||
|
||||
it('should show an error if API returns no audio data', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: No audio data returned`);
|
||||
mockedAiApiClient.generateSpeechFromText.mockResolvedValue({
|
||||
json: async () => null, // Simulate falsy response
|
||||
} as Response);
|
||||
@@ -151,9 +174,11 @@ describe('VoiceLabPage', () => {
|
||||
await waitFor(() => {
|
||||
expect(notifyError).toHaveBeenCalledWith('The AI did not return any audio data.');
|
||||
});
|
||||
console.log(`${LOG_PREFIX} Test: No audio data returned passed`);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects in catch block', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: Non-error object rejection`);
|
||||
mockedAiApiClient.generateSpeechFromText.mockRejectedValue('A simple string error');
|
||||
render(<VoiceLabPage />);
|
||||
|
||||
@@ -166,6 +191,7 @@ describe('VoiceLabPage', () => {
|
||||
});
|
||||
|
||||
it('should allow replaying the generated audio', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: Replay functionality`);
|
||||
mockedAiApiClient.generateSpeechFromText.mockResolvedValue({
|
||||
json: async () => 'mock-audio-data',
|
||||
} as Response);
|
||||
@@ -173,27 +199,40 @@ describe('VoiceLabPage', () => {
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /generate & play/i });
|
||||
|
||||
console.log(`${LOG_PREFIX} Clicking generate button for replay test`);
|
||||
fireEvent.click(generateButton);
|
||||
|
||||
// Wait for the replay button to appear
|
||||
console.log(`${LOG_PREFIX} Waiting for replay button to appear`);
|
||||
const replayButton = await screen.findByTestId('replay-button');
|
||||
console.log(`${LOG_PREFIX} Replay button found`);
|
||||
|
||||
// Verify initial play happened
|
||||
await waitFor(() => expect(mockAudioPlay).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Verifying initial play count: 1`);
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Click the replay button
|
||||
console.log(`${LOG_PREFIX} Clicking replay button`);
|
||||
fireEvent.click(replayButton);
|
||||
|
||||
// Verify that play was called a second time
|
||||
await waitFor(() => expect(mockAudioPlay).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => {
|
||||
console.log(`${LOG_PREFIX} Verifying play count reaches 2. Current calls: ${mockAudioPlay.mock.calls.length}`);
|
||||
expect(mockAudioPlay).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
console.log(`${LOG_PREFIX} Test: Replay functionality passed`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-time Voice Session', () => {
|
||||
it('should call startVoiceSession and show an error notification', async () => {
|
||||
console.log(`${LOG_PREFIX} Test: Real-time voice session error`);
|
||||
// The startVoiceSession function is a stub that will throw an error.
|
||||
// We can mock it to control the error message for the test.
|
||||
mockedAiApiClient.startVoiceSession.mockImplementation(() => {
|
||||
console.log(`${LOG_PREFIX} mocked startVoiceSession called`);
|
||||
throw new Error('WebSocket proxy not implemented.');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user