mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge branch 'Dev' into Dev
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export { useNavigatorData } from './useNavigatorData';
|
||||
export { useNavigatorFavourite } from './useNavigatorFavourite';
|
||||
export { useNavigatorSearch } from './useNavigatorSearch';
|
||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
|
||||
const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set<number>() });
|
||||
|
||||
describe('navigatorFavouritesStore', () =>
|
||||
{
|
||||
beforeEach(reset);
|
||||
|
||||
it('setAll replaces membership and coerces ids to numbers', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ '1', 2, '3' ] as any);
|
||||
const { ids } = useNavigatorFavouritesStore.getState();
|
||||
expect(ids.has(1)).toBe(true);
|
||||
expect(ids.has(2)).toBe(true);
|
||||
expect(ids.has(3)).toBe(true);
|
||||
expect(ids.size).toBe(3);
|
||||
});
|
||||
|
||||
it('apply(true) adds and apply(false) removes', () =>
|
||||
{
|
||||
const { apply } = useNavigatorFavouritesStore.getState();
|
||||
apply(7, true);
|
||||
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(true);
|
||||
apply(7, false);
|
||||
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('apply returns the same state reference when nothing changes (no needless re-render)', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
|
||||
const before = useNavigatorFavouritesStore.getState().ids;
|
||||
useNavigatorFavouritesStore.getState().apply(5, true); // already present
|
||||
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
|
||||
useNavigatorFavouritesStore.getState().apply(99, false); // already absent
|
||||
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
|
||||
});
|
||||
|
||||
it('apply creates a new Set reference when membership actually changes', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
|
||||
const before = useNavigatorFavouritesStore.getState().ids;
|
||||
useNavigatorFavouritesStore.getState().apply(6, true);
|
||||
expect(useNavigatorFavouritesStore.getState().ids).not.toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createNitroStore } from '../../state/createNitroStore';
|
||||
|
||||
export type NavigatorFavouritesState = {
|
||||
ids: Set<number>;
|
||||
};
|
||||
|
||||
export type NavigatorFavouritesActions = {
|
||||
setAll(roomIds: number[]): void;
|
||||
apply(roomId: number, added: boolean): void;
|
||||
};
|
||||
|
||||
export const useNavigatorFavouritesStore = createNitroStore<NavigatorFavouritesState & NavigatorFavouritesActions>()((set) => ({
|
||||
ids: new Set<number>(),
|
||||
|
||||
setAll: (roomIds) => set({ ids: new Set(roomIds.map(Number)) }),
|
||||
apply: (roomId, added) => set((s) =>
|
||||
{
|
||||
const id = Number(roomId);
|
||||
if(added ? s.ids.has(id) : !s.ids.has(id)) return s;
|
||||
const ids = new Set(s.ids);
|
||||
if(added) ids.add(id);
|
||||
else ids.delete(id);
|
||||
return { ids };
|
||||
})
|
||||
}));
|
||||
@@ -4,13 +4,13 @@ import { useNavigatorStore } from './useNavigatorStore';
|
||||
export const useNavigatorData = () =>
|
||||
{
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
} = useBetween(useNavigatorStore);
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ToggleFavoriteRoom } from '../../api';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
|
||||
export const useNavigatorFavourite = (roomId: number) =>
|
||||
{
|
||||
const isFavourite = useNavigatorFavouritesStore((s) => s.ids.has(Number(roomId)));
|
||||
|
||||
const toggle = useCallback(() => ToggleFavoriteRoom(Number(roomId), isFavourite), [ roomId, isFavourite ]);
|
||||
|
||||
return { isFavourite, toggle };
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { FlatCreatedEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
||||
@@ -13,23 +11,12 @@ import { useNavigatorSearch } from './useNavigatorSearch';
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a fresh QueryClient with retries off so failures are immediate. */
|
||||
const makeQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 }
|
||||
}
|
||||
});
|
||||
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
|
||||
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
|
||||
// the UI blank when the listener never matched. These tests exercise the
|
||||
// event-driven implementation directly — no QueryClient scaffolding.
|
||||
|
||||
/** Wrapper factory — each test gets its own QueryClient instance. */
|
||||
const makeWrapper = (client: QueryClient) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={ client }>
|
||||
{ children }
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
|
||||
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
|
||||
const makeSearchEvent = (code: string) =>
|
||||
{
|
||||
// Cast constructors as `any` so tsgo doesn't check required args against
|
||||
@@ -66,7 +53,6 @@ describe('useNavigatorSearch', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
// Reset UI store state before each test
|
||||
useNavigatorUiStore.setState(INITIAL_UI);
|
||||
});
|
||||
|
||||
@@ -76,59 +62,44 @@ describe('useNavigatorSearch', () =>
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
|
||||
it('1. with empty tabCode no fetch starts (the request effect is gated)', () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// Dispatch a search event — should be ignored (query disabled)
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Data must stay null
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
// No tab selected → the request effect short-circuits, nothing fetches.
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
});
|
||||
|
||||
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
|
||||
it('2. after setTab("public"), the hook starts fetching and a matching event resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// Activate the query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
// Hook should start fetching
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Simulate server response
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Query should resolve with the matching result
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () =>
|
||||
it('3. after setFilter("cocco"), a new fetch fires and a matching event resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// First establish a tab
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
// Resolve the initial query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
@@ -136,7 +107,6 @@ describe('useNavigatorSearch', () =>
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Now set a filter — triggers new query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
@@ -144,7 +114,6 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with matching event
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
@@ -152,24 +121,19 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
|
||||
// Confirm filter is set
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
||||
});
|
||||
|
||||
it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () =>
|
||||
it('4. after setTab("events"), currentFilter resets to "" and a new fetch fires for events', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
// Establish public tab with a filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
useNavigatorUiStore.getState().setFilter('some-filter');
|
||||
});
|
||||
|
||||
// Resolve the public+filter query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
@@ -177,20 +141,16 @@ describe('useNavigatorSearch', () =>
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Switch to events tab — should atomically reset filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('events');
|
||||
});
|
||||
|
||||
// Filter must be cleared
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||
|
||||
// New query for 'events' fires
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with events result
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
|
||||
@@ -202,8 +162,7 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
act(() =>
|
||||
{
|
||||
@@ -226,8 +185,7 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
const { result } = renderHook(() => useNavigatorSearch());
|
||||
|
||||
act(() =>
|
||||
{
|
||||
@@ -236,67 +194,20 @@ describe('useNavigatorSearch', () =>
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Dispatch an event for a DIFFERENT tab — should be rejected by accept filter
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
|
||||
});
|
||||
|
||||
// Still fetching — the wrong-tab event was ignored
|
||||
// (the query promise stays pending until it times out or a matching event arrives)
|
||||
// After the wrong-tab dispatch, data should NOT be updated
|
||||
// The wrong-tab event is filtered out by the accept guard.
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
|
||||
// Now dispatch the correct one to unblock the test
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
// Only the correct-tab result is stored
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
});
|
||||
|
||||
it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
|
||||
// Spy on invalidateQueries to confirm the invalidator calls it
|
||||
const invalidateSpy = vi.spyOn(client, 'invalidateQueries');
|
||||
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Establish a resolved query so there is something to invalidate
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Dispatch FlatCreatedEvent — should trigger invalidateQueries
|
||||
const flatCreatedEv = new (FlatCreatedEvent as any)() as any;
|
||||
flatCreatedEv.getParser = () => ({ roomId: 999 });
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(flatCreatedEv as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());
|
||||
|
||||
// The invalidation should target the 'navigator', 'search' key prefix
|
||||
const calls = invalidateSpy.mock.calls;
|
||||
const calledWithSearchKey = calls.some(call =>
|
||||
{
|
||||
const opts = call[0] as any;
|
||||
const key: string[] = opts?.queryKey ?? [];
|
||||
return key[0] === 'navigator' && key[1] === 'search';
|
||||
});
|
||||
expect(calledWithSearchKey).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
@@ -23,7 +22,6 @@ export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
|
||||
const [ isFetching, setIsFetching ] = useState(false);
|
||||
@@ -49,11 +47,9 @@ export const useNavigatorSearch = () =>
|
||||
setIsFetching(false);
|
||||
});
|
||||
|
||||
// A newly created room invalidates the current search so it refetches.
|
||||
// A newly created room refetches the current search.
|
||||
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, () =>
|
||||
{
|
||||
queryClient.invalidateQueries({ queryKey: [ 'navigator', 'search' ] });
|
||||
|
||||
if(!tabCode) return;
|
||||
|
||||
setIsFetching(true);
|
||||
|
||||
@@ -8,7 +8,7 @@ describe('navigator filter shapes (smoke)', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorData());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
||||
'categories', 'eventCategories',
|
||||
'navigatorData', 'navigatorSearches',
|
||||
'topLevelContext', 'topLevelContexts'
|
||||
].sort());
|
||||
|
||||
@@ -18,13 +18,13 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
||||
TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNotification } from '../notification';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorStore = () =>
|
||||
{
|
||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
@@ -48,21 +48,13 @@ export const useNavigatorStore = () =>
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
||||
setFavouriteRoomIds(favoriteIds);
|
||||
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const roomId = Number(parser.flatId);
|
||||
const added = !!parser.added;
|
||||
setFavouriteRoomIds(prev =>
|
||||
{
|
||||
const ids = (prev || []).map((x: any) => Number(x));
|
||||
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
|
||||
return ids.filter(id => id !== roomId);
|
||||
});
|
||||
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||
@@ -280,7 +272,7 @@ export const useNavigatorStore = () =>
|
||||
}, []));
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user