diff --git a/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js b/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js index 7d79e7f98..fe2778cda 100644 --- a/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js +++ b/jam-ui/src/store/features/__tests__/sessionChatSlice.test.js @@ -1046,58 +1046,249 @@ describe('sessionChat selectors', () => { }); describe('selectChatMessages', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectChatMessages; + + beforeAll(() => { + // Import will be added after implementation + try { + const slice = require('../sessionChatSlice'); + selectChatMessages = slice.selectChatMessages; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns messages for specified channel', () => { + if (!selectChatMessages) { + expect(true).toBe(false); // RED phase + return; + } + const messages = selectChatMessages(mockState, 'session-abc'); + expect(messages).toHaveLength(2); + expect(messages[0].id).toBe('msg-1'); + expect(messages[1].id).toBe('msg-2'); + }); + + test('returns empty array for non-existent channel', () => { + if (!selectChatMessages) { + expect(true).toBe(false); // RED phase + return; + } + const messages = selectChatMessages(mockState, 'session-nonexistent'); + expect(messages).toEqual([]); + }); + + test('memoizes result for same channel', () => { + if (!selectChatMessages) { + expect(true).toBe(false); // RED phase + return; + } + const result1 = selectChatMessages(mockState, 'session-abc'); + const result2 = selectChatMessages(mockState, 'session-abc'); + expect(result1).toBe(result2); // Same reference }); }); describe('selectUnreadCount', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectUnreadCount; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectUnreadCount = slice.selectUnreadCount; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns unread count for channel', () => { + if (!selectUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const count = selectUnreadCount(mockState, 'global'); + expect(count).toBe(5); + }); + + test('returns 0 for channel with no unread', () => { + if (!selectUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const count = selectUnreadCount(mockState, 'session-abc'); + expect(count).toBe(0); + }); + + test('returns 0 for non-existent channel', () => { + if (!selectUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const count = selectUnreadCount(mockState, 'nonexistent'); + expect(count).toBe(0); }); }); describe('selectTotalUnreadCount', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectTotalUnreadCount; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectTotalUnreadCount = slice.selectTotalUnreadCount; + } catch (e) { + // Selector not yet exported + } + }); + + test('sums all unread counts across channels', () => { + if (!selectTotalUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const total = selectTotalUnreadCount(mockState); + expect(total).toBe(8); // 0 + 5 + 3 + }); + + test('returns 0 when no unread messages', () => { + if (!selectTotalUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const emptyState = { + sessionChat: { unreadCounts: {} } + }; + const total = selectTotalUnreadCount(emptyState); + expect(total).toBe(0); + }); + + test('memoizes result', () => { + if (!selectTotalUnreadCount) { + expect(true).toBe(false); // RED phase + return; + } + const result1 = selectTotalUnreadCount(mockState); + const result2 = selectTotalUnreadCount(mockState); + expect(result1).toBe(result2); }); }); describe('selectIsChatWindowOpen', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectIsChatWindowOpen; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectIsChatWindowOpen = slice.selectIsChatWindowOpen; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns window open state', () => { + if (!selectIsChatWindowOpen) { + expect(true).toBe(false); // RED phase + return; + } + const isOpen = selectIsChatWindowOpen(mockState); + expect(isOpen).toBe(true); }); }); describe('selectActiveChannel', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectActiveChannel; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectActiveChannel = slice.selectActiveChannel; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns active channel', () => { + if (!selectActiveChannel) { + expect(true).toBe(false); // RED phase + return; + } + const channel = selectActiveChannel(mockState); + expect(channel).toBe('session-abc'); }); }); describe('selectFetchStatus', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectFetchStatus; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectFetchStatus = slice.selectFetchStatus; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns fetch status for channel', () => { + if (!selectFetchStatus) { + expect(true).toBe(false); // RED phase + return; + } + const status = selectFetchStatus(mockState, 'session-abc'); + expect(status).toBe('succeeded'); + }); + + test('returns idle for non-existent channel', () => { + if (!selectFetchStatus) { + expect(true).toBe(false); // RED phase + return; + } + const status = selectFetchStatus(mockState, 'nonexistent'); + expect(status).toBe('idle'); }); }); describe('selectSendStatus', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectSendStatus; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectSendStatus = slice.selectSendStatus; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns send status', () => { + if (!selectSendStatus) { + expect(true).toBe(false); // RED phase + return; + } + const status = selectSendStatus(mockState); + expect(status).toBe('idle'); }); }); describe('selectSendError', () => { - test('should fail - selector not implemented yet', () => { - // RED phase: selector doesn't exist yet - expect(true).toBe(false); + let selectSendError; + + beforeAll(() => { + try { + const slice = require('../sessionChatSlice'); + selectSendError = slice.selectSendError; + } catch (e) { + // Selector not yet exported + } + }); + + test('returns send error', () => { + if (!selectSendError) { + expect(true).toBe(false); // RED phase + return; + } + const error = selectSendError(mockState); + expect(error).toBeNull(); }); }); }); diff --git a/jam-ui/src/store/features/sessionChatSlice.js b/jam-ui/src/store/features/sessionChatSlice.js index fc8c2bb9e..9830a3d10 100644 --- a/jam-ui/src/store/features/sessionChatSlice.js +++ b/jam-ui/src/store/features/sessionChatSlice.js @@ -308,4 +308,96 @@ export const { setWindowPosition } = sessionChatSlice.actions; +// Phase 7 Plan 3: Memoized selectors using createSelector from Redux Toolkit +import { createSelector } from '@reduxjs/toolkit'; + +// Input selectors (direct state access) +const selectSessionChatState = (state) => state.sessionChat; +const selectMessagesByChannel = (state) => state.sessionChat.messagesByChannel; +const selectUnreadCounts = (state) => state.sessionChat.unreadCounts; +const selectFetchStatusMap = (state) => state.sessionChat.fetchStatus; + +/** + * Select messages for a specific channel + * @param {Object} state - Redux state + * @param {string} channel - Channel ID + * @returns {Array} Messages for the channel + */ +export const selectChatMessages = createSelector( + [selectMessagesByChannel, (state, channel) => channel], + (messagesByChannel, channel) => messagesByChannel[channel] || [] +); + +/** + * Select unread count for a specific channel + * @param {Object} state - Redux state + * @param {string} channel - Channel ID + * @returns {number} Unread count for the channel + */ +export const selectUnreadCount = createSelector( + [selectUnreadCounts, (state, channel) => channel], + (unreadCounts, channel) => unreadCounts[channel] || 0 +); + +/** + * Select total unread count across all channels + * @param {Object} state - Redux state + * @returns {number} Total unread count + */ +export const selectTotalUnreadCount = createSelector( + [selectUnreadCounts], + (unreadCounts) => Object.values(unreadCounts).reduce((sum, count) => sum + count, 0) +); + +/** + * Select whether chat window is open + * @param {Object} state - Redux state + * @returns {boolean} Is window open + */ +export const selectIsChatWindowOpen = createSelector( + [selectSessionChatState], + (chatState) => chatState.isWindowOpen +); + +/** + * Select active channel ID + * @param {Object} state - Redux state + * @returns {string|null} Active channel ID + */ +export const selectActiveChannel = createSelector( + [selectSessionChatState], + (chatState) => chatState.activeChannel +); + +/** + * Select fetch status for a specific channel + * @param {Object} state - Redux state + * @param {string} channel - Channel ID + * @returns {string} Fetch status ('idle', 'loading', 'succeeded', 'failed') + */ +export const selectFetchStatus = createSelector( + [selectFetchStatusMap, (state, channel) => channel], + (fetchStatus, channel) => fetchStatus[channel] || 'idle' +); + +/** + * Select send status + * @param {Object} state - Redux state + * @returns {string} Send status ('idle', 'loading', 'succeeded', 'failed') + */ +export const selectSendStatus = createSelector( + [selectSessionChatState], + (chatState) => chatState.sendStatus +); + +/** + * Select send error message + * @param {Object} state - Redux state + * @returns {string|null} Send error message + */ +export const selectSendError = createSelector( + [selectSessionChatState], + (chatState) => chatState.sendError +); + export default sessionChatSlice.reducer;