feat(07-03): implement 8 memoized selectors with Reselect

- Add selectChatMessages: returns messages for channel with memoization
- Add selectUnreadCount: returns unread count for channel
- Add selectTotalUnreadCount: sums all unread counts across channels
- Add selectIsChatWindowOpen: returns window open state
- Add selectActiveChannel: returns active channel ID
- Add selectFetchStatus: returns fetch status for channel
- Add selectSendStatus: returns send status
- Add selectSendError: returns send error message
- All selectors use createSelector from Redux Toolkit for memoization
- All 68 tests passing (GREEN phase), including 15 selector tests

Part of Phase 7 Plan 3 (WebSocket Integration & Selectors)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-27 12:43:03 +05:30
parent 555adcf100
commit 306e2637ad
2 changed files with 307 additions and 24 deletions

View File

@ -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();
});
});
});

View File

@ -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;