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:
parent
555adcf100
commit
306e2637ad
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue