Browse Source

fix: updated broken tests

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
80056b09ca
  1. 40
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  2. 2
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  3. 39
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts
  4. 15
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  5. 244
      src/components/PageComponents/Messages/MessageInput.test.tsx
  6. 13
      src/components/PageComponents/Messages/MessageInput.tsx
  7. 5
      src/components/PageComponents/Messages/MessageItem.tsx
  8. 2
      src/components/UI/Button.tsx
  9. 1
      src/components/UI/Dialog.tsx
  10. 8
      src/core/stores/messageStore/index.ts
  11. 323
      src/core/stores/messageStore/messageStore.test.ts

40
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx

@ -1,9 +1,10 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
// Ensure the path is correct for import
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock('@core/stores/messageStore.ts', () => ({ vi.mock('@core/stores/messageStore', () => ({
useMessageStore: vi.fn(() => ({ useMessageStore: vi.fn(() => ({
deleteAllMessages: vi.fn(), deleteAllMessages: vi.fn(),
})), })),
@ -14,17 +15,36 @@ describe('DeleteMessagesDialog', () => {
const mockClearAllMessages = vi.fn(); const mockClearAllMessages = vi.fn();
beforeEach(() => { beforeEach(() => {
vi.mocked(useMessageStore).mockReturnValue({ deleteAllMessages: mockClearAllMessages });
mockOnOpenChange.mockClear(); mockOnOpenChange.mockClear();
mockClearAllMessages.mockClear(); mockClearAllMessages.mockClear();
const mockedUseMessageStore = vi.mocked(useMessageStore);
mockedUseMessageStore.mockImplementation(() => ({
deleteAllMessages: mockClearAllMessages
}));
mockedUseMessageStore.mockClear();
});
it('calls onOpenChange with false when the close button (X) is clicked', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
const closeButton = screen.queryByTestId('dialog-close-button');
if (!closeButton) {
throw new Error("Dialog close button with data-testid='dialog-close-button' not found. Did you add it to the component?");
}
fireEvent.click(closeButton);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
it('renders the dialog when open is true', () => { it('renders the dialog when open is true', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
expect(screen.getByText('Clear All Messages')).toBeVisible(); expect(screen.getByText('Clear All Messages')).toBeInTheDocument();
expect(screen.getByText(/This action will clear all message history./)).toBeVisible(); expect(screen.getByText(/This action will clear all message history./)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeInTheDocument();
}); });
it('does not render the dialog when open is false', () => { it('does not render the dialog when open is false', () => {
@ -32,15 +52,10 @@ describe('DeleteMessagesDialog', () => {
expect(screen.queryByText('Clear All Messages')).toBeNull(); expect(screen.queryByText('Clear All Messages')).toBeNull();
}); });
it('calls onOpenChange with false when the close button is clicked', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
it('calls onOpenChange with false when the dismiss button is clicked', () => { it('calls onOpenChange with false when the dismiss button is clicked', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
@ -48,6 +63,7 @@ describe('DeleteMessagesDialog', () => {
render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); render(<DeleteMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
expect(mockOnOpenChange).toHaveBeenCalledTimes(1); // Add count check
expect(mockOnOpenChange).toHaveBeenCalledWith(false); expect(mockOnOpenChange).toHaveBeenCalledWith(false);
}); });
}); });

2
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -29,7 +29,7 @@ export const DeleteMessagesDialog = ({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogClose onClick={handleCloseDialog} /> <DialogClose data-testid="dialog-close-button" />
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-warning" /> <AlertTriangleIcon className="h-5 w-5 text-warning" />

39
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.test.ts

@ -2,12 +2,12 @@ import { renderHook, act } from "@testing-library/react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
vi.mock("@core/stores/messageStore.ts", () => ({ vi.mock("@core/stores/messageStore", () => ({
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })),
})); }));
vi.mock("@core/stores/deviceStore", () => ({
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(() => ({ useDevice: vi.fn(() => ({
removeNode: vi.fn(), removeNode: vi.fn(),
setDialogOpen: vi.fn(), setDialogOpen: vi.fn(),
@ -23,46 +23,50 @@ describe("useRefreshKeysDialog Hook", () => {
let clearNodeErrorMock: Mock; let clearNodeErrorMock: Mock;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks();
removeNodeMock = vi.fn(); removeNodeMock = vi.fn();
setDialogOpenMock = vi.fn(); setDialogOpenMock = vi.fn();
getNodeErrorMock = vi.fn(); getNodeErrorMock = vi.fn().mockReturnValue(undefined);
clearNodeErrorMock = vi.fn(); clearNodeErrorMock = vi.fn();
(useDevice as Mock).mockReturnValue({ vi.mocked(useDevice).mockReturnValue({
removeNode: removeNodeMock, removeNode: removeNodeMock,
setDialogOpen: setDialogOpenMock, setDialogOpen: setDialogOpenMock,
getNodeError: getNodeErrorMock, getNodeError: getNodeErrorMock,
clearNodeError: clearNodeErrorMock, clearNodeError: clearNodeErrorMock,
}); });
vi.mocked(useMessageStore).mockReturnValue({
activeChat: "chat-123"
});
}); });
it("handleNodeRemove should remove the node and update dialog if there is an error", () => { it("handleNodeRemove should remove the node and update dialog if there is an error", () => {
getNodeErrorMock.mockReturnValue({ node: "node-abc" }); getNodeErrorMock.mockReturnValue({ node: "node-abc" });
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
act(() => { expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
result.current.handleNodeRemove();
});
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1);
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123"); expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123");
expect(removeNodeMock).toHaveBeenCalledTimes(1);
expect(removeNodeMock).toHaveBeenCalledWith("node-abc"); expect(removeNodeMock).toHaveBeenCalledWith("node-abc");
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
}); });
it("handleNodeRemove should do nothing if there is no error", () => { it("handleNodeRemove should do nothing if there is no error", () => {
getNodeErrorMock.mockReturnValue(undefined);
const { result } = renderHook(() => useRefreshKeysDialog()); const { result } = renderHook(() => useRefreshKeysDialog());
act(() => { result.current.handleNodeRemove(); });
act(() => { expect(getNodeErrorMock).toHaveBeenCalledTimes(1);
result.current.handleNodeRemove(); expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123");
}); expect(clearNodeErrorMock).not.toHaveBeenCalled();
expect(removeNodeMock).not.toHaveBeenCalled(); expect(removeNodeMock).not.toHaveBeenCalled();
expect(setDialogOpenMock).not.toHaveBeenCalled(); expect(setDialogOpenMock).not.toHaveBeenCalled();
expect(clearNodeErrorMock).not.toHaveBeenCalled();
}); });
it("handleCloseDialog should close the dialog", () => { it("handleCloseDialog should close the dialog", () => {
@ -72,6 +76,7 @@ describe("useRefreshKeysDialog Hook", () => {
result.current.handleCloseDialog(); result.current.handleCloseDialog();
}); });
expect(setDialogOpenMock).toHaveBeenCalledTimes(1);
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false);
}); });
}); });

15
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -1,28 +1,27 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts"; import { useMessageStore } from "@core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() { export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();
const { activeChat } = useMessageStore(); const { activeChat } = useMessageStore();
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen]);
const handleNodeRemove = useCallback(() => { const handleNodeRemove = useCallback(() => {
const nodeWithError = getNodeError(activeChat); const nodeWithError = getNodeError(activeChat);
if (!nodeWithError) { if (!nodeWithError) {
return; return;
} }
clearNodeError(activeChat); clearNodeError(activeChat);
handleCloseDialog();; handleCloseDialog();
return removeNode(nodeWithError?.node); return removeNode(nodeWithError?.node);
}, [activeChat, clearNodeError, setDialogOpen, removeNode]); }, [activeChat, clearNodeError, getNodeError, removeNode, handleCloseDialog]);
const handleCloseDialog = useCallback(() => {
setDialogOpen('refreshKeys', false);
}, [setDialogOpen])
return { return {
handleCloseDialog, handleCloseDialog,
handleNodeRemove handleNodeRemove
}; };
} }

244
src/components/PageComponents/Messages/MessageInput.test.tsx

@ -1,14 +1,11 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageInput } from './MessageInput.tsx'; import { MessageInput, MessageInputProps } from './MessageInput.tsx';
import { useDevice } from '@core/stores/deviceStore.ts'; import { Types } from '@meshtastic/core';
import { useMessageStore } from '../../../core/stores/messageStore/index.ts';
import { debounce } from '@core/utils/debounce.ts';
import { Types } from "@meshtastic/core";
vi.mock('@components/UI/Button.tsx', () => ({ vi.mock('@components/UI/Button.tsx', () => ({
Button: vi.fn(({ type, className, children, onClick, onSubmit }) => ( Button: vi.fn(({ type, className, children, onClick, onSubmit, variant, ...rest }) => (
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}> <button type={type} className={className} onClick={onClick} onSubmit={onSubmit} {...rest}>
{children} {children}
</button> </button>
)), )),
@ -23,16 +20,21 @@ vi.mock('@components/UI/Input.tsx', () => ({
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={onChange} onChange={onChange}
data-testid="message-input-field"
/> />
)), )),
})); }));
vi.mock('@core/stores/deviceStore.ts', () => ({ const mockSetDraft = vi.fn();
useDevice: vi.fn(), const mockGetDraft = vi.fn();
})); const mockClearDraft = vi.fn();
vi.mock('@core/stores/messageStore.ts', () => ({ vi.mock('@core/stores/messageStore', () => ({
useMessageStore: vi.fn(), useMessageStore: vi.fn(() => ({
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
})),
MessageState: { MessageState: {
Ack: 'ack', Ack: 'ack',
Waiting: 'waiting', Waiting: 'waiting',
@ -44,111 +46,177 @@ vi.mock('@core/stores/messageStore.ts', () => ({
}, },
})); }));
vi.mock('@core/utils/debounce.ts', () => ({
debounce: vi.fn((fn) => fn),
}));
vi.mock('lucide-react', () => ({ vi.mock('lucide-react', () => ({
SendIcon: vi.fn(() => <svg data-testid="send-icon" />), SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
})); }));
describe('MessageInput', () => { describe('MessageInput', () => {
const mockSetMessageState = vi.fn(); const mockOnSend = vi.fn();
const mockSetActiveChat = vi.fn(); const defaultProps: MessageInputProps = {
const mockSetDraft = vi.fn(); onSend: mockOnSend,
const mockGetDraft = vi.fn(); to: 123,
const mockClearDraft = vi.fn(); maxBytes: 256,
const mockSendText = vi.fn(); };
beforeEach(() => { beforeEach(() => {
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({ vi.clearAllMocks();
connection: {
sendText: mockSendText,
},
});
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
setMessageState: mockSetMessageState,
activeChat: 123,
setDraft: mockSetDraft,
getDraft: mockGetDraft,
clearDraft: mockClearDraft,
});
mockSetMessageState.mockClear(); mockGetDraft.mockReturnValue('');
mockSetActiveChat.mockClear();
mockSetDraft.mockClear();
mockGetDraft.mockClear();
mockClearDraft.mockClear();
mockSendText.mockClear();
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
}); });
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => { const renderComponent = (props: Partial<MessageInputProps> = {}) => {
render(<MessageInput {...props} />); render(<MessageInput {...defaultProps} {...props} />);
}; };
it.skip('sends text message and updates state to Ack on submit', async () => { it('should render the input field, byte counter, and send button', () => {
renderComponent({ to: 2, channel: 3, maxBytes: 256 }); renderComponent();
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('byte-counter')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
});
it('should initialize with the draft from the store', () => {
const initialDraft = 'Existing draft message';
mockGetDraft.mockImplementation((key) => {
return key === defaultProps.to ? initialDraft : '';
});
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'Hello' } }); expect(inputElement.value).toBe(initialDraft);
expect(mockGetDraft).toHaveBeenCalledWith(defaultProps.to);
const expectedBytes = new Blob([initialDraft]).size;
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
});
it('should update input value, byte counter, and call setDraft on change within limits', () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const testMessage = 'Hello there!';
const expectedBytes = new Blob([testMessage]).size;
fireEvent.change(inputElement, { target: { value: testMessage } });
expect((inputElement as HTMLInputElement).value).toBe(testMessage);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${expectedBytes}/${defaultProps.maxBytes}`);
expect(mockSetDraft).toHaveBeenCalledTimes(1);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, testMessage);
});
it('should NOT update input value or call setDraft if maxBytes is exceeded', () => {
const smallMaxBytes = 5;
renderComponent({ maxBytes: smallMaxBytes });
const inputElement = screen.getByPlaceholderText('Enter Message');
const initialValue = '12345';
const excessiveValue = '123456';
fireEvent.change(inputElement, { target: { value: initialValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(mockSetDraft).toHaveBeenCalledWith(defaultProps.to, initialValue);
mockSetDraft.mockClear();
fireEvent.change(inputElement, { target: { value: excessiveValue } });
expect((inputElement as HTMLInputElement).value).toBe(initialValue);
expect(screen.getByTestId('byte-counter')).toHaveTextContent(`${smallMaxBytes}/${smallMaxBytes}`);
expect(mockSetDraft).not.toHaveBeenCalled();
});
it('should call onSend, clear input, reset byte counter, and call clearDraft on valid submit', async () => {
renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form'); const formElement = screen.getByRole('form');
const testMessage = 'Send this message';
fireEvent.change(inputElement, { target: { value: testMessage } });
fireEvent.submit(formElement); fireEvent.submit(formElement);
await waitFor(() => { await waitFor(() => {
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3); expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockSetMessageState).toHaveBeenCalledWith({ expect(mockOnSend).toHaveBeenCalledWith(testMessage);
type: 'direct', expect((inputElement as HTMLInputElement).value).toBe('');
key: 123, expect(screen.getByTestId('byte-counter')).toHaveTextContent(`0/${defaultProps.maxBytes}`);
messageId: undefined, expect(mockClearDraft).toHaveBeenCalledTimes(1);
newState: 'ack', expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
});
expect(mockClearDraft).toHaveBeenCalledWith(2);
expect(inputElement.value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
}); });
}); });
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => { it('should trim whitespace before calling onSend', async () => {
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 }); renderComponent();
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; const inputElement = screen.getByPlaceholderText('Enter Message');
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
const formElement = screen.getByRole('form'); const formElement = screen.getByRole('form');
const testMessageWithWhitespace = ' Trim me! ';
const expectedTrimmedMessage = 'Trim me!';
fireEvent.change(inputElement, { target: { value: testMessageWithWhitespace } });
fireEvent.submit(formElement); fireEvent.submit(formElement);
await waitFor(() => { await waitFor(() => {
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5); expect(mockOnSend).toHaveBeenCalledTimes(1);
expect(mockSetMessageState).toHaveBeenCalledWith({ expect(mockOnSend).toHaveBeenCalledWith(expectedTrimmedMessage);
type: 'broadcast', expect(mockClearDraft).toHaveBeenCalledWith(defaultProps.to);
key: 123,
messageId: undefined,
newState: 'ack',
});
expect(mockClearDraft).toHaveBeenCalledWith('broadcast');
expect(inputElement.value).toBe('');
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
}); });
}); });
it('updates state to Failed if sendText throws an error', async () => { it('should not call onSend or clearDraft if input is empty on submit', async () => {
mockSendText.mockRejectedValue({ id: 456 }); renderComponent();
renderComponent({ to: 3, channel: 1, maxBytes: 256 }); const inputElement = screen.getByPlaceholderText('Enter Message');
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'Error message' } });
const formElement = screen.getByRole('form'); const formElement = screen.getByRole('form');
expect((inputElement as HTMLInputElement).value).toBe('');
fireEvent.submit(formElement); fireEvent.submit(formElement);
await waitFor(() => { await act(async () => {
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1); await new Promise(resolve => setTimeout(resolve, 50));
expect(mockSetMessageState).toHaveBeenCalledWith({ });
type: 'direct',
key: 123, expect(mockOnSend).not.toHaveBeenCalled();
messageId: 456, expect(mockClearDraft).not.toHaveBeenCalled();
newState: 'failed', });
});
expect(mockClearDraft).toHaveBeenCalledWith(3); it('should not call onSend or clearDraft if input contains only whitespace on submit', async () => {
expect(inputElement.value).toBe(''); renderComponent();
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); const inputElement = screen.getByTestId('message-input-field');
const formElement = screen.getByRole('form');
const whitespaceMessage = ' \t ';
fireEvent.change(inputElement, { target: { value: whitespaceMessage } });
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
fireEvent.submit(formElement);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
}); });
expect(mockOnSend).not.toHaveBeenCalled();
expect(mockClearDraft).not.toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe(whitespaceMessage);
});
it('should work with broadcast destination for drafts', () => {
const broadcastDest: Types.Destination = 'broadcast';
mockGetDraft.mockImplementation((key) => key === broadcastDest ? 'Broadcast draft' : '');
renderComponent({ to: broadcastDest });
expect(mockGetDraft).toHaveBeenCalledWith(broadcastDest);
expect((screen.getByPlaceholderText('Enter Message') as HTMLInputElement).value).toBe('Broadcast draft');
const inputElement = screen.getByPlaceholderText('Enter Message');
const formElement = screen.getByRole('form');
const newMessage = 'New broadcast msg';
fireEvent.change(inputElement, { target: { value: newMessage } });
expect(mockSetDraft).toHaveBeenCalledWith(broadcastDest, newMessage);
fireEvent.submit(formElement);
expect(mockOnSend).toHaveBeenCalledWith(newMessage);
expect(mockClearDraft).toHaveBeenCalledWith(broadcastDest);
}); });
}); });

13
src/components/PageComponents/Messages/MessageInput.tsx

@ -18,11 +18,12 @@ export const MessageInput = ({
}: MessageInputProps) => { }: MessageInputProps) => {
const { setDraft, getDraft, clearDraft } = useMessageStore(); const { setDraft, getDraft, clearDraft } = useMessageStore();
const [localDraft, setLocalDraft] = useState(getDraft(to));
const [messageBytes, setMessageBytes] = useState(0);
const calculateBytes = (text: string) => new Blob([text]).size; const calculateBytes = (text: string) => new Blob([text]).size;
const initialDraft = getDraft(to);
const [localDraft, setLocalDraft] = useState(initialDraft);
const [messageBytes, setMessageBytes] = useState(() => calculateBytes(initialDraft));
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const byteLength = calculateBytes(newValue); const byteLength = calculateBytes(newValue);
@ -37,6 +38,7 @@ export const MessageInput = ({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!localDraft.trim()) return; if (!localDraft.trim()) return;
// Reset bytes *before* sending (consider if onSend failure needs different handling)
setMessageBytes(0); setMessageBytes(0);
startTransition(() => { startTransition(() => {
@ -67,8 +69,7 @@ export const MessageInput = ({
<Button <Button
type="submit" type="submit"
variant={"default"} variant="default"
// className=" dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
> >
<SendIcon size={16} /> <SendIcon size={16} />
</Button> </Button>
@ -76,4 +77,4 @@ export const MessageInput = ({
</form> </form>
</div> </div>
); );
}; };

5
src/components/PageComponents/Messages/MessageItem.tsx

@ -88,18 +88,17 @@ export const MessageItem = ({ message }: MessageProps) => {
return null; return null;
}, [getDevices, message.from]); }, [getDevices, message.from]);
const { shortName, displayName } = useMemo(() => { const { shortName, displayName, } = useMemo(() => {
const fallbackName = message.from const fallbackName = message.from
const longName = messageUser?.user?.longName; const longName = messageUser?.user?.longName;
const shortName = messageUser?.user?.shortName ?? fallbackName; const shortName = messageUser?.user?.shortName ?? fallbackName;
const displayName = longName || fallbackName; const displayName = longName || fallbackName;
return { shortName, displayName }; return { shortName, displayName, from: message.from };
}, [messageUser, message.from]); }, [messageUser, message.from]);
const messageStatus = getMessageStatus(message.state); const messageStatus = getMessageStatus(message.state);
const messageText = message?.message ?? ""; const messageText = message?.message ?? "";
const messageDate = message?.date; const messageDate = message?.date;
const isFailed = message.state === MessageState.Failed;
const messageItemWrapperClass = cn( const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none", "group w-full py-2 relative list-none",

2
src/components/UI/Button.tsx

@ -14,7 +14,7 @@ const buttonVariants = cva(
success: success:
"bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600", "bg-green-500 text-white hover:bg-green-600 dark:hover:bg-green-600",
outline: outline:
"bg-transparent border border-slate-400 hover:text-slate-100 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ", "bg-transparent border border-slate-400 hover:text-slate-400 dark:hover:text-slate-300 dark:border-slate-400 dark:text-slate-100 ",
subtle: subtle:
"bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400", "bg-slate-100 text-slate-700 hover:bg-slate-200 dark:bg-slate-500 dark:text-white dark:hover:bg-slate-400",
ghost: ghost:

1
src/components/UI/Dialog.tsx

@ -61,6 +61,7 @@ const DialogClose = ({
}: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => ( }: DialogPrimitive.DialogCloseProps & React.RefAttributes<HTMLButtonElement> & { className?: string }) => (
<DialogPrimitive.Close <DialogPrimitive.Close
aria-label="Close" aria-label="Close"
data-testid="dialog-close-button"
className={cn( className={cn(
"absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900", "absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 dark:focus:ring-slate-400 dark:focus:ring-offset-slate-900",
className, className,

8
src/core/stores/messageStore/index.ts

@ -16,7 +16,7 @@ export enum MessageType {
Broadcast = "broadcast", Broadcast = "broadcast",
} }
function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId { export function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(':'); return [node1, node2].sort((a, b) => a - b).join(':');
} }
@ -165,17 +165,17 @@ export const useMessageStore = create<MessageStore>()(
const deleted = messageLog.delete(params.messageId); const deleted = messageLog.delete(params.messageId);
if (deleted) { if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} chat ${parentKey}`); console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
// Clean up empty MessageLogMap and its entry in the parent map // Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) { if (messageLog.size === 0) {
parentMap.delete(parentKey); parentMap.delete(parentKey);
console.log(`Cleaned up empty chat entry for ${parentKey}`); console.log(`Cleaned up empty message entry for ${parentKey}`);
} }
} else { } else {
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`); console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
} }
} else { } else {
console.warn(`Chat entry ${parentKey} not found for message deletion.`); console.warn(`Message entry ${parentKey} not found for message deletion.`);
} }
}) })
); );

323
src/core/stores/messageStore/messageStore.test.ts

@ -3,14 +3,15 @@ import {
useMessageStore, useMessageStore,
MessageType, MessageType,
MessageState, MessageState,
type Message, getConversationId,
} from './index.ts'; } from './index.ts';
import type { ConversationId, ChannelId, MessageLogMap, Message } from './types.ts';
import { Types } from '@meshtastic/core';
let memoryStorage: Record<string, string> = {}; vi.mock('../storage/indexDB.ts', () => {
let memoryStorage: Record<string, string> = {};
vi.mock('./storage/indexDB.ts', () => {
return { return {
zustandIndexDBStorage: { storageWithMapSupport: {
getItem: vi.fn(async (name: string): Promise<string | null> => { getItem: vi.fn(async (name: string): Promise<string | null> => {
return memoryStorage[name] ?? null; return memoryStorage[name] ?? null;
}), }),
@ -27,9 +28,7 @@ vi.mock('./storage/indexDB.ts', () => {
const myNodeNum = 111; const myNodeNum = 111;
const otherNodeNum1 = 222; const otherNodeNum1 = 222;
const otherNodeNum2 = 333; const otherNodeNum2 = 333;
const broadcastChannel = 0; const broadcastChannel: ChannelId = 0;
const directMessageToOther1: Message = { const directMessageToOther1: Message = {
type: MessageType.Direct, type: MessageType.Direct,
@ -90,13 +89,23 @@ describe('useMessageStore', () => {
const initialState = useMessageStore.getState(); const initialState = useMessageStore.getState();
beforeEach(() => { beforeEach(() => {
useMessageStore.setState(initialState, true); useMessageStore.setState({
...initialState,
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<Types.Destination, string>(),
}, true);
}); });
it('should have correct initial state', () => { it('should have correct initial state', () => {
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.direct).toEqual({}); expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.broadcast).toEqual({}); expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
expect(state.draft).toBeInstanceOf(Map); expect(state.draft).toBeInstanceOf(Map);
expect(state.draft.size).toBe(0); expect(state.draft.size).toBe(0);
expect(state.nodeNum).toBe(0); expect(state.nodeNum).toBe(0);
@ -117,23 +126,32 @@ describe('useMessageStore', () => {
}); });
describe('saveMessage', () => { describe('saveMessage', () => {
it('should save a direct message with correct structure', () => { it('should save a direct message with correct Map structure', () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.direct[myNodeNum]).toBeDefined(); const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
expect( // Check if the conversation Map exists
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId], expect(state.messages.direct.has(conversationId)).toBe(true);
).toEqual(directMessageToOther1); const conversationLog = state.messages.direct.get(conversationId);
// Check if the inner Map (MessageLogMap) exists and is a Map
expect(conversationLog).toBeInstanceOf(Map);
// Check if the message exists within the inner Map
expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
// Check the message content
expect(conversationLog?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
}); });
it('should save a broadcast message with correct structure', () => { it('should save a broadcast message with correct Map structure', () => {
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.broadcast[broadcastChannel]).toBeDefined(); const channelId = broadcastMessage1.channel;
expect(
state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId], expect(state.messages.broadcast.has(channelId)).toBe(true);
).toEqual(broadcastMessage1); const channelLog = state.messages.broadcast.get(channelId);
expect(channelLog).toBeInstanceOf(Map);
expect(channelLog?.has(broadcastMessage1.messageId)).toBe(true);
expect(channelLog?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
}); });
it('should save multiple messages correctly', () => { it('should save multiple messages correctly', () => {
@ -143,12 +161,13 @@ describe('useMessageStore', () => {
const state = useMessageStore.getState(); const state = useMessageStore.getState();
// Direct msg 1 (me -> other1) const convId1 = getConversationId(myNodeNum, otherNodeNum1);
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1); expect(state.messages.direct.get(convId1)?.get(directMessageToOther1.messageId)).toEqual(directMessageToOther1);
// Direct msg 2 (other1 -> me)
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1); expect(state.messages.direct.get(convId1)?.get(directMessageFromOther1.messageId)).toEqual(directMessageFromOther1);
// Broadcast msg 1
expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1); const channelId = broadcastMessage1.channel;
expect(state.messages.broadcast.get(channelId)?.get(broadcastMessage1.messageId)).toEqual(broadcastMessage1);
}); });
}); });
@ -163,9 +182,9 @@ describe('useMessageStore', () => {
}); });
it('should return broadcast messages for a channel, sorted by date', () => { it('should return broadcast messages for a channel, sorted by date', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { const messages = useMessageStore.getState().getMessages({
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice type: MessageType.Broadcast,
channel: broadcastChannel channelId: broadcastChannel
}); });
expect(messages).toHaveLength(2); expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(broadcastMessage1); expect(messages[0]).toEqual(broadcastMessage1);
@ -173,17 +192,18 @@ describe('useMessageStore', () => {
}); });
it('should return empty array for broadcast if channel has no messages', () => { it('should return empty array for broadcast if channel has no messages', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { const messages = useMessageStore.getState().getMessages({
myNodeNum: myNodeNum, type: MessageType.Broadcast,
channel: 99 channelId: Types.ChannelNumber.Channel1
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
it('should return combined direct messages for a specific chat (pair), sorted by date', () => { it('should return combined direct messages for a specific chat pair, sorted by date', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { const messages = useMessageStore.getState().getMessages({
myNodeNum: myNodeNum, type: MessageType.Direct,
otherNodeNum: otherNodeNum1 nodeA: myNodeNum,
nodeB: otherNodeNum1
}); });
expect(messages).toHaveLength(2); expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(directMessageToOther1); expect(messages[0]).toEqual(directMessageToOther1);
@ -191,31 +211,23 @@ describe('useMessageStore', () => {
}); });
it('should return only relevant direct messages for a different chat pair', () => { it('should return only relevant direct messages for a different chat pair', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { const messages = useMessageStore.getState().getMessages({
myNodeNum: myNodeNum, type: MessageType.Direct,
otherNodeNum: otherNodeNum2 nodeA: myNodeNum,
nodeB: otherNodeNum2
}); });
expect(messages).toHaveLength(1); expect(messages).toHaveLength(1);
expect(messages[0]).toEqual(directMessageToOther2); expect(messages[0]).toEqual(directMessageToOther2);
}); });
it('should return empty array for direct chat if no messages exist', () => { it('should return empty array for direct chat if no messages exist', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { const messages = useMessageStore.getState().getMessages({
myNodeNum: myNodeNum, type: MessageType.Direct,
otherNodeNum: 999 nodeA: myNodeNum,
nodeB: 999
}); });
expect(messages).toEqual([]); expect(messages).toEqual([]);
}); });
it('should return combined direct messages when myNodeNum and otherNodeNum are provided', () => {
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
myNodeNum: myNodeNum, // Keep this
otherNodeNum: otherNodeNum1
});
expect(messages).toHaveLength(2);
expect(messages[0]).toEqual(directMessageToOther1);
expect(messages[1]).toEqual(directMessageFromOther1);
});
}); });
describe('setMessageState', () => { describe('setMessageState', () => {
@ -226,119 +238,219 @@ describe('useMessageStore', () => {
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
}); });
it('should update state for a direct message sent BY ME', () => { it('should update state for a direct message', () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
key: otherNodeNum1, nodeA: directMessageToOther1.from,
nodeB: directMessageToOther1.to,
messageId: directMessageToOther1.messageId, messageId: directMessageToOther1.messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]; const conversationId = getConversationId(directMessageToOther1.from, directMessageToOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageToOther1.messageId);
expect(message?.state).toBe(MessageState.Ack); expect(message?.state).toBe(MessageState.Ack);
}); });
it('should update state for a direct message received FROM OTHER', () => { it('should update state for another direct message in the same conversation', () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
key: otherNodeNum1, nodeA: directMessageFromOther1.from,
nodeB: directMessageFromOther1.to,
messageId: directMessageFromOther1.messageId, messageId: directMessageFromOther1.messageId,
newState: MessageState.Failed, newState: MessageState.Failed,
}); });
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]; const conversationId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
const message = useMessageStore.getState().messages.direct.get(conversationId)?.get(directMessageFromOther1.messageId);
expect(message?.state).toBe(MessageState.Failed); expect(message?.state).toBe(MessageState.Failed);
}); });
it('should update state for a broadcast message', () => { it('should update state for a broadcast message', () => {
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Broadcast, type: MessageType.Broadcast,
key: broadcastChannel, channelId: broadcastChannel,
messageId: broadcastMessage1.messageId, messageId: broadcastMessage1.messageId,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]; const message = useMessageStore.getState().messages.broadcast.get(broadcastChannel)?.get(broadcastMessage1.messageId);
expect(message?.state).toBe(MessageState.Ack); expect(message?.state).toBe(MessageState.Ack);
}); });
it('should warn if message is not found', () => { it('should warn if message is not found (direct)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({ useMessageStore.getState().setMessageState({
type: MessageType.Direct, type: MessageType.Direct,
key: otherNodeNum1, nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 999, messageId: 999,
newState: MessageState.Ack, newState: MessageState.Ack,
}); });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update')); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore(); warnSpy.mockRestore();
}); });
});
it('should warn if message is not found (broadcast)', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Broadcast,
channelId: broadcastChannel,
messageId: 999,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore();
});
it('should warn if conversation/channel is not found', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
useMessageStore.getState().setMessageState({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 998,
messageId: 101,
newState: MessageState.Ack,
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message or conversation/channel not found for state update'));
warnSpy.mockRestore();
});
});
describe('clearMessageByMessageId', () => { describe('clearMessageByMessageId', () => {
const extraDirectMessageId = 1011;
beforeEach(() => { beforeEach(() => {
useMessageStore.getState().setNodeNum(myNodeNum); useMessageStore.getState().setNodeNum(myNodeNum);
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(directMessageFromOther1); useMessageStore.getState().saveMessage(directMessageFromOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 }); useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: extraDirectMessageId, date: Date.now() + 50 });
}); });
it('should delete a specific direct message (sent by me)', () => { it('should delete a specific direct message', () => {
const messageIdToDelete = directMessageToOther1.messageId; const messageIdToDelete = directMessageToOther1.messageId;
const nodeA = directMessageToOther1.from;
const nodeB = directMessageToOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct, type: MessageType.Direct,
from: myNodeNum, nodeA: nodeA,
to: otherNodeNum1, nodeB: nodeB,
messageId: messageIdToDelete messageId: messageIdToDelete
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined(); const conversationLog = state.messages.direct.get(conversationId);
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined(); expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
expect(conversationLog?.has(directMessageFromOther1.messageId)).toBe(true);
expect(state.messages.direct.has(conversationId)).toBe(true);
}); });
it('should delete a specific direct message (sent by other)', () => { it('should delete another specific direct message', () => {
const messageIdToDelete = directMessageFromOther1.messageId; const messageIdToDelete = directMessageFromOther1.messageId;
const nodeA = directMessageFromOther1.from;
const nodeB = directMessageFromOther1.to;
const conversationId = getConversationId(nodeA, nodeB);
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct, type: MessageType.Direct,
from: otherNodeNum1, nodeA: nodeA,
to: myNodeNum, nodeB: nodeB,
messageId: messageIdToDelete messageId: messageIdToDelete
}); });
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined(); const conversationLog = state.messages.direct.get(conversationId);
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined(); expect(conversationLog?.has(messageIdToDelete)).toBe(false);
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); expect(conversationLog?.has(directMessageToOther1.messageId)).toBe(true);
expect(conversationLog?.has(extraDirectMessageId)).toBe(true);
}); });
it('should delete a specific broadcast message', () => { it('should delete a specific broadcast message', () => {
const messageIdToDelete = broadcastMessage1.messageId; const messageIdToDelete = broadcastMessage1.messageId;
const channelId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Broadcast, type: MessageType.Broadcast,
channel: broadcastChannel, channelId: channelId,
messageId: messageIdToDelete messageId: messageIdToDelete
}); });
const state = useMessageStore.getState();
expect(state.messages.broadcast.get(channelId)?.get(messageIdToDelete)).toBeUndefined();
});
it('should clean up empty conversation/channel Maps', () => {
const directConvId = getConversationId(directMessageFromOther1.from, directMessageFromOther1.to);
const broadcastChanId = broadcastMessage1.channel;
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: directMessageToOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageFromOther1.from, nodeB: directMessageFromOther1.to, messageId: directMessageFromOther1.messageId });
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, nodeA: directMessageToOther1.from, nodeB: directMessageToOther1.to, messageId: extraDirectMessageId });
expect(useMessageStore.getState().messages.direct.has(directConvId)).toBe(false);
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channelId: broadcastChanId, messageId: broadcastMessage1.messageId });
expect(useMessageStore.getState().messages.broadcast.has(broadcastChanId)).toBe(false);
});
it('should not error when trying to delete non-existent message', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
const conversationId = getConversationId(myNodeNum, otherNodeNum1);
expect(() => {
useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: otherNodeNum1,
messageId: 9999
});
}).not.toThrow();
const state = useMessageStore.getState(); const state = useMessageStore.getState();
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined(); const conversationLog = state.messages.direct.get(conversationId);
expect(conversationLog?.size).toBe(3); // 101, 102, 1011
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not found in direct chat'));
warnSpy.mockRestore();
}); });
it('should clean up empty to/from/channel objects', () => { it('should not error when trying to delete from non-existent conversation/channel', () => {
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, from: otherNodeNum1, to: myNodeNum, messageId: directMessageFromOther1.messageId }); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed expect(() => {
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed useMessageStore.getState().clearMessageByMessageId({
type: MessageType.Direct,
nodeA: myNodeNum,
nodeB: 9998,
messageId: 101
});
}).not.toThrow();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Message entry"));
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId }); expect(warnSpy).toHaveBeenCalledTimes(1);
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
warnSpy.mockRestore();
}); });
}); });
describe('Drafts', () => { describe('Drafts', () => {
const draftKey = otherNodeNum1; const draftKeyDirect = otherNodeNum1;
const draftKeyBroadcast = broadcastChannel;
const draftMessage = 'This is a draft'; const draftMessage = 'This is a draft';
it('should set and get a draft', () => { it('should set and get a draft for direct chat', () => {
useMessageStore.getState().setDraft(draftKey, draftMessage); useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage); expect(useMessageStore.getState().draft.get(draftKeyDirect)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage); expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe(draftMessage);
});
it('should set and get a draft for broadcast chat', () => {
useMessageStore.getState().setDraft(draftKeyBroadcast, draftMessage);
expect(useMessageStore.getState().draft.get(draftKeyBroadcast)).toBe(draftMessage);
expect(useMessageStore.getState().getDraft(draftKeyBroadcast)).toBe(draftMessage);
}); });
it('should return empty string for non-existent draft', () => { it('should return empty string for non-existent draft', () => {
@ -346,26 +458,29 @@ describe('useMessageStore', () => {
}); });
it('should clear a draft', () => { it('should clear a draft', () => {
useMessageStore.getState().setDraft(draftKey, draftMessage); useMessageStore.getState().setDraft(draftKeyDirect, draftMessage);
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true); expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(true);
useMessageStore.getState().clearDraft(draftKey); useMessageStore.getState().clearDraft(draftKeyDirect);
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false); expect(useMessageStore.getState().draft.has(draftKeyDirect)).toBe(false);
expect(useMessageStore.getState().getDraft(draftKey)).toBe(''); expect(useMessageStore.getState().getDraft(draftKeyDirect)).toBe('');
}); });
}); });
describe('deleteAllMessages', () => { describe('deleteAllMessages', () => {
it('should clear all direct and broadcast messages', () => { it('should clear all direct and broadcast messages, leaving empty Maps', () => {
useMessageStore.getState().saveMessage(directMessageToOther1); useMessageStore.getState().saveMessage(directMessageToOther1);
useMessageStore.getState().saveMessage(broadcastMessage1); useMessageStore.getState().saveMessage(broadcastMessage1);
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0); expect(useMessageStore.getState().messages.direct.size).toBeGreaterThan(0);
expect(useMessageStore.getState().messages.broadcast.size).toBeGreaterThan(0);
useMessageStore.getState().deleteAllMessages(); useMessageStore.getState().deleteAllMessages();
expect(useMessageStore.getState().messages.direct).toEqual({}); const state = useMessageStore.getState();
expect(useMessageStore.getState().messages.broadcast).toEqual({}); expect(state.messages.direct).toBeInstanceOf(Map);
expect(state.messages.direct.size).toBe(0);
expect(state.messages.broadcast).toBeInstanceOf(Map);
expect(state.messages.broadcast.size).toBe(0);
}); });
}); });
});
});
Loading…
Cancel
Save