mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 01:11:32 +02:00
Add missing tests and address AGENTS.md gaps
This commit is contained in:
+40
-16
@@ -18,33 +18,47 @@ Keep it aligned with `frontend/src` source code.
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
├── styles.css # Additional global app styles
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── useConversationMessages.ts
|
||||
│ ├── useUnreadCounts.ts
|
||||
│ ├── useRepeaterMode.ts
|
||||
│ └── useAirtimeTracking.ts
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRepeaterMode.ts # Repeater login/command workflow
|
||||
│ ├── useAirtimeTracking.ts # Repeater airtime stats polling
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
├── utils/
|
||||
│ ├── urlHash.ts
|
||||
│ ├── conversationState.ts
|
||||
│ ├── favorites.ts
|
||||
│ ├── messageParser.ts
|
||||
│ ├── pathUtils.ts
|
||||
│ ├── pubkey.ts
|
||||
│ └── contactAvatar.ts
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
│ ├── favorites.ts # LocalStorage migration for favorites
|
||||
│ ├── messageParser.ts # Message text → rendered segments
|
||||
│ ├── pathUtils.ts # Distance/validation helpers for paths + map
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ └── lastViewedConversation.ts # localStorage for last-viewed conversation
|
||||
├── components/
|
||||
│ ├── StatusBar.tsx
|
||||
│ ├── Sidebar.tsx
|
||||
│ ├── ChatHeader.tsx # Conversation header (trace, favorite, delete)
|
||||
│ ├── MessageList.tsx
|
||||
│ ├── MessageInput.tsx
|
||||
│ ├── NewMessageModal.tsx
|
||||
│ ├── SettingsModal.tsx
|
||||
│ ├── settingsConstants.ts # Settings section ordering and labels
|
||||
│ ├── RawPacketList.tsx
|
||||
│ ├── MapView.tsx
|
||||
│ ├── VisualizerView.tsx
|
||||
@@ -53,8 +67,12 @@ frontend/src/
|
||||
│ ├── CrackerPanel.tsx
|
||||
│ ├── BotCodeEditor.tsx
|
||||
│ ├── ContactAvatar.tsx
|
||||
│ └── ui/
|
||||
│ └── ui/ # shadcn/ui primitives
|
||||
├── types/
|
||||
│ └── d3-force-3d.d.ts # Type declarations for d3-force-3d
|
||||
└── test/
|
||||
├── setup.ts
|
||||
├── fixtures/websocket_events.json
|
||||
├── api.test.ts
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
@@ -64,26 +82,32 @@ frontend/src/
|
||||
├── messageParser.test.ts
|
||||
├── pathUtils.test.ts
|
||||
├── radioPresets.test.ts
|
||||
├── rawPacketIdentity.test.ts
|
||||
├── repeaterMode.test.ts
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useRepeaterMode.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
├── websocket.test.ts
|
||||
└── setup.ts
|
||||
└── websocket.test.ts
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### State ownership
|
||||
|
||||
`App.tsx` orchestrates high-level state (health, config, contacts/channels, active conversation, UI flags).
|
||||
Specialized logic is delegated to hooks:
|
||||
`App.tsx` orchestrates high-level state and delegates to hooks:
|
||||
- `useRadioControl`: radio health/config state, reconnect/reboot polling
|
||||
- `useAppSettings`: settings CRUD, favorites, preferences migration
|
||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||
- `useConversationRouter`: URL hash → active conversation routing
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRepeaterMode`: repeater login/command workflow
|
||||
- `useAirtimeTracking`: repeater airtime stats polling
|
||||
|
||||
### Initial load + realtime
|
||||
|
||||
|
||||
@@ -319,6 +319,10 @@ export function useConversationMessages(
|
||||
fetchingConversationIdRef.current = newId;
|
||||
prevConversationIdRef.current = newId;
|
||||
|
||||
// Reset loadingOlder — the previous conversation's in-flight older-message
|
||||
// fetch is irrelevant now (its stale-check will discard the response).
|
||||
setLoadingOlder(false);
|
||||
|
||||
// Clear state for new conversation
|
||||
if (!activeConversation || activeConversation.type === 'raw') {
|
||||
setMessages([]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import * as messageCache from '../messageCache';
|
||||
import { useConversationMessages } from '../hooks/useConversationMessages';
|
||||
@@ -124,3 +124,98 @@ describe('useConversationMessages ACK ordering', () => {
|
||||
expect(result.current.messages[0].paths).toEqual(highAckPaths);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages conversation switch', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('resets loadingOlder when switching conversations mid-fetch', async () => {
|
||||
const convA: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
|
||||
const convB: Conversation = { type: 'contact', id: 'conv_b', name: 'Contact B' };
|
||||
|
||||
// Conv A initial fetch: return 200 messages so hasOlderMessages = true
|
||||
const fullPage = Array.from({ length: 200 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i + 1,
|
||||
conversation_key: 'conv_a',
|
||||
text: `msg-${i}`,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
received_at: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
mockGetMessages.mockResolvedValueOnce(fullPage);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ conv }: { conv: Conversation }) => useConversationMessages(conv),
|
||||
{ initialProps: { conv: convA } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.hasOlderMessages).toBe(true);
|
||||
expect(result.current.messages).toHaveLength(200);
|
||||
|
||||
// Start fetching older messages — use a deferred promise so it stays in-flight
|
||||
const olderDeferred = createDeferred<Message[]>();
|
||||
mockGetMessages.mockReturnValueOnce(olderDeferred.promise);
|
||||
|
||||
act(() => {
|
||||
result.current.fetchOlderMessages();
|
||||
});
|
||||
|
||||
expect(result.current.loadingOlder).toBe(true);
|
||||
|
||||
// Switch to conv B while older-messages fetch is still pending
|
||||
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
|
||||
rerender({ conv: convB });
|
||||
|
||||
// loadingOlder must reset immediately — no phantom spinner in conv B
|
||||
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
|
||||
// Resolve the stale older-messages fetch — should not affect conv B's state
|
||||
olderDeferred.resolve([
|
||||
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
|
||||
]);
|
||||
|
||||
// Give the stale response time to be processed (it should be discarded)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
|
||||
it('aborts in-flight fetch when switching conversations', async () => {
|
||||
const convA: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
|
||||
const convB: Conversation = { type: 'contact', id: 'conv_b', name: 'Contact B' };
|
||||
|
||||
// Conv A: never resolves (simulates slow network)
|
||||
mockGetMessages.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ conv }: { conv: Conversation }) => useConversationMessages(conv),
|
||||
{ initialProps: { conv: convA } }
|
||||
);
|
||||
|
||||
// Should be loading
|
||||
expect(result.current.messagesLoading).toBe(true);
|
||||
|
||||
// Verify the API was called with an AbortSignal
|
||||
const firstCallSignal = (mockGetMessages as Mock).mock.calls[0]?.[1];
|
||||
expect(firstCallSignal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
// Switch to conv B
|
||||
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 1, conversation_key: 'conv_b' })]);
|
||||
rerender({ conv: convB });
|
||||
|
||||
// The signal from conv A's fetch should have been aborted
|
||||
expect(firstCallSignal.aborted).toBe(true);
|
||||
|
||||
// Conv B should load normally
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user