diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 58151dee..86e2c864 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -29,6 +29,14 @@ import { isTeamTaskNeedsFixActionable, } from '@shared/utils/teamTaskState'; +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, +} from '../team/teamDataRequestKeys'; import { selectTeamDataForName } from '../team/teamDataSelectors'; import { areInboxMessageArraysEquivalent, @@ -155,38 +163,6 @@ interface RefreshTeamDataOptions { withDedup?: boolean; } -type TeamDataSnapshotMode = 'full' | 'thin'; - -function normalizeTeamGetDataOptions(options?: TeamGetDataOptions): TeamGetDataOptions | undefined { - return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; -} - -function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { - return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; -} - -function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { - return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; -} - -function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; -} - -function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { - const normalizedOptions = normalizeTeamGetDataOptions(options); - return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; -} - -function getFullTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName); -} - -function getThinTeamDataRequestKey(teamName: string): string { - return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); -} - function hasFullTeamDataRequestForTeam(teamName: string): boolean { return inFlightTeamDataRequests.has(getFullTeamDataRequestKey(teamName)); } @@ -196,9 +172,8 @@ function hasThinTeamDataRequestForTeam(teamName: string): boolean { } function clearTeamDataRequestsForTeam(teamName: string): void { - const prefix = `${teamName}\u0000`; for (const key of inFlightTeamDataRequests.keys()) { - if (key.startsWith(prefix)) { + if (isTeamDataRequestKeyForTeam(key, teamName)) { inFlightTeamDataRequests.delete(key); } } diff --git a/src/renderer/store/team/teamDataRequestKeys.ts b/src/renderer/store/team/teamDataRequestKeys.ts new file mode 100644 index 00000000..32e87773 --- /dev/null +++ b/src/renderer/store/team/teamDataRequestKeys.ts @@ -0,0 +1,39 @@ +import type { TeamGetDataOptions } from '@shared/types'; + +export type TeamDataSnapshotMode = 'full' | 'thin'; + +export function normalizeTeamGetDataOptions( + options?: TeamGetDataOptions +): TeamGetDataOptions | undefined { + return options?.includeMemberBranches === false ? { includeMemberBranches: false } : undefined; +} + +export function shouldIncludeMemberBranches(options?: TeamGetDataOptions): boolean { + return normalizeTeamGetDataOptions(options)?.includeMemberBranches !== false; +} + +export function getTeamDataSnapshotMode(options?: TeamGetDataOptions): TeamDataSnapshotMode { + return shouldIncludeMemberBranches(options) ? 'full' : 'thin'; +} + +export function getTeamDataRequestKey(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `${teamName}\u0000mode:${getTeamDataSnapshotMode(normalizedOptions)}`; +} + +export function getTeamDataRequestLabel(teamName: string, options?: TeamGetDataOptions): string { + const normalizedOptions = normalizeTeamGetDataOptions(options); + return `team:getData(${teamName},mode=${getTeamDataSnapshotMode(normalizedOptions)})`; +} + +export function getFullTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName); +} + +export function getThinTeamDataRequestKey(teamName: string): string { + return getTeamDataRequestKey(teamName, { includeMemberBranches: false }); +} + +export function isTeamDataRequestKeyForTeam(requestKey: string, teamName: string): boolean { + return requestKey.startsWith(`${teamName}\u0000`); +} diff --git a/test/renderer/store/teamDataRequestKeys.test.ts b/test/renderer/store/teamDataRequestKeys.test.ts new file mode 100644 index 00000000..9409c541 --- /dev/null +++ b/test/renderer/store/teamDataRequestKeys.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { + getFullTeamDataRequestKey, + getTeamDataRequestKey, + getTeamDataRequestLabel, + getTeamDataSnapshotMode, + getThinTeamDataRequestKey, + isTeamDataRequestKeyForTeam, + normalizeTeamGetDataOptions, + shouldIncludeMemberBranches, +} from '../../../src/renderer/store/team/teamDataRequestKeys'; + +describe('teamDataRequestKeys', () => { + it('normalizes only the thin snapshot option and treats all other inputs as full snapshots', () => { + expect(normalizeTeamGetDataOptions()).toBeUndefined(); + expect(normalizeTeamGetDataOptions({})).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: true })).toBeUndefined(); + expect(normalizeTeamGetDataOptions({ includeMemberBranches: false })).toEqual({ + includeMemberBranches: false, + }); + + expect(shouldIncludeMemberBranches()).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: true })).toBe(true); + expect(shouldIncludeMemberBranches({ includeMemberBranches: false })).toBe(false); + }); + + it('maps normalized request options to stable full and thin snapshot modes', () => { + expect(getTeamDataSnapshotMode()).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: true })).toBe('full'); + expect(getTeamDataSnapshotMode({ includeMemberBranches: false })).toBe('thin'); + }); + + it('builds request keys that preserve the existing null-separated team/mode contract', () => { + expect(getTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: true })).toBe( + 'my-team\u0000mode:full' + ); + expect(getTeamDataRequestKey('my-team', { includeMemberBranches: false })).toBe( + 'my-team\u0000mode:thin' + ); + expect(getFullTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:full'); + expect(getThinTeamDataRequestKey('my-team')).toBe('my-team\u0000mode:thin'); + }); + + it('builds timeout/debug labels from the same normalized mode policy', () => { + expect(getTeamDataRequestLabel('my-team')).toBe('team:getData(my-team,mode=full)'); + expect(getTeamDataRequestLabel('my-team', { includeMemberBranches: false })).toBe( + 'team:getData(my-team,mode=thin)' + ); + }); + + it('matches request keys only for the exact team prefix boundary', () => { + expect(isTeamDataRequestKeyForTeam('my-team\u0000mode:full', 'my-team')).toBe(true); + expect(isTeamDataRequestKeyForTeam('my-team-extra\u0000mode:full', 'my-team')).toBe(false); + expect(isTeamDataRequestKeyForTeam('my-team', 'my-team')).toBe(false); + }); +});