diff --git a/src/renderer/components/notifications/NotificationList.test.tsx b/src/renderer/components/notifications/NotificationList.test.tsx index be985d0b8..981258c85 100644 --- a/src/renderer/components/notifications/NotificationList.test.tsx +++ b/src/renderer/components/notifications/NotificationList.test.tsx @@ -8,15 +8,12 @@ import { mockGitHubEnterpriseServerAccount, } from '../../__mocks__/account-mocks'; import { - mockGitifyNotification, mockGithubEnterpriseGitifyNotifications, mockMultipleAccountNotifications, } from '../../__mocks__/notifications-mocks'; import { GroupBy } from '../../types'; -import { getAccountUUID } from '../../utils/auth/utils'; -import { buildAccountItems, buildRepositoryItems } from '../../utils/notifications/listItems'; import { NotificationList } from './NotificationList'; // LegendList virtualizes based on real scroll metrics, which happy-dom cannot @@ -32,220 +29,7 @@ vi.mock('@legendapp/list/react', () => ({ }) =>
{data.map((item) => renderItem({ item }))}
, })); -type BuildItemsContext = Parameters[1]; - -function makeContext(overrides: Partial = {}): BuildItemsContext { - return { - showAccountHeader: false, - hasMultipleAccounts: false, - collapsedAccounts: new Set(), - collapsedRepositories: new Set(), - animatingNotifications: new Set(), - animatingRepositories: new Map>(), - groupBy: GroupBy.REPOSITORY, - ...overrides, - }; -} - -function notification(id: string, repoFullName: string, order: number) { - return { - ...mockGitifyNotification, - id, - order, - repository: { - ...(mockGitifyNotification.repository as NonNullable< - typeof mockGitifyNotification.repository - >), - fullName: repoFullName, - }, - }; -} - describe('renderer/components/notifications/NotificationList.tsx', () => { - describe('buildRepositoryItems', () => { - const accountUUID = getAccountUUID(mockGitHubCloudAccount); - - it('flattens repositories in first-seen order with their expanded notifications', () => { - const repoA1 = notification('a-1', 'owner/repo-a', 0); - const repoB1 = notification('b-1', 'owner/repo-b', 1); - const repoA2 = notification('a-2', 'owner/repo-a', 2); - - const items = buildRepositoryItems(accountUUID, [repoA1, repoB1, repoA2], makeContext()); - - expect(items.map((item) => item.kind)).toEqual([ - 'repository', - 'notification', - 'notification', - 'repository', - 'notification', - ]); - expect(items.map((item) => item.key)).toEqual([ - `repository-${accountUUID}-owner/repo-a`, - `notification-${accountUUID}:a-1`, - `notification-${accountUUID}:a-2`, - `repository-${accountUUID}-owner/repo-b`, - `notification-${accountUUID}:b-1`, - ]); - - const [repoA, , , repoB] = items; - expect(repoA).toMatchObject({ - kind: 'repository', - repoKey: `${accountUUID}-owner/repo-a`, - repoName: 'owner/repo-a', - notifications: [repoA1, repoA2], - }); - expect(repoB).toMatchObject({ kind: 'repository', repoName: 'owner/repo-b' }); - }); - - it('emits only the repository header when the repository is collapsed', () => { - const repoA1 = notification('a-1', 'owner/repo-a', 0); - const repoA2 = notification('a-2', 'owner/repo-a', 1); - - const items = buildRepositoryItems( - accountUUID, - [repoA1, repoA2], - makeContext({ collapsedRepositories: new Set([`${accountUUID}-owner/repo-a`]) }), - ); - - expect(items.map((item) => item.kind)).toEqual(['repository']); - expect(items[0]).toMatchObject({ key: `repository-${accountUUID}-owner/repo-a` }); - }); - - it('marks notifications as animating exit from either animation source', () => { - const repoA1 = notification('a-1', 'owner/repo-a', 0); - const repoA2 = notification('a-2', 'owner/repo-a', 1); - const repoB1 = notification('b-1', 'owner/repo-b', 2); - - const items = buildRepositoryItems( - accountUUID, - [repoA1, repoA2, repoB1], - makeContext({ - animatingNotifications: new Set([`${accountUUID}:a-1`]), - animatingRepositories: new Map([ - [`${accountUUID}-owner/repo-b`, new Set([`${accountUUID}:b-1`])], - ]), - }), - ); - - const animating = items.filter( - (item) => item.kind === 'notification' && item.isAnimatingExit, - ); - expect(animating.map((item) => item.key)).toEqual([ - `notification-${accountUUID}:a-1`, - `notification-${accountUUID}:b-1`, - ]); - }); - - it('returns an empty list when there are no notifications', () => { - expect(buildRepositoryItems(accountUUID, [], makeContext())).toEqual([]); - }); - }); - - describe('buildAccountItems', () => { - const account = mockGitHubCloudAccount; - const accountUUID = getAccountUUID(account); - - it('emits the account header before sorted notification rows', () => { - const outOfOrder = [ - notification('zz', 'owner/repo-a', 2), - notification('aa', 'owner/repo-a', 0), - notification('mm', 'owner/repo-a', 1), - ]; - - const items = buildAccountItems( - { account, notifications: outOfOrder, error: null }, - makeContext({ showAccountHeader: true, groupBy: GroupBy.DATE }), - ); - - expect(items.map((item) => item.key)).toEqual([ - `account-${accountUUID}`, - `notification-${accountUUID}:aa`, - `notification-${accountUUID}:mm`, - `notification-${accountUUID}:zz`, - ]); - expect(items[0]).toMatchObject({ kind: 'account', account, count: 3 }); - }); - - it('marks notification rows as animating exit', () => { - const items = buildAccountItems( - { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, - makeContext({ - groupBy: GroupBy.DATE, - animatingNotifications: new Set([`${accountUUID}:n-1`]), - }), - ); - - expect(items).toEqual([ - { - key: `notification-${accountUUID}:n-1`, - kind: 'notification', - notification: notification('n-1', 'owner/repo-a', 0), - isAnimatingExit: true, - }, - ]); - }); - - it('returns only the account header when the account is collapsed', () => { - const items = buildAccountItems( - { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, - makeContext({ - showAccountHeader: true, - collapsedAccounts: new Set([accountUUID]), - groupBy: GroupBy.DATE, - }), - ); - - expect(items.map((item) => item.kind)).toEqual(['account']); - }); - - it('emits an error row, full height when it is the only account', () => { - const error = { title: 'Account failed', descriptions: ['Try again'], emojis: ['🔥'] }; - const entry: Parameters[0] = { - account, - notifications: [notification('n-1', 'owner/repo-a', 0)], - error, - }; - - const singleAccount = buildAccountItems(entry, makeContext({ groupBy: GroupBy.DATE })); - const multipleAccounts = buildAccountItems( - entry, - makeContext({ hasMultipleAccounts: true, groupBy: GroupBy.DATE }), - ); - - expect(singleAccount.map((item) => item.kind)).toEqual(['error', 'notification']); - expect(singleAccount[0]).toMatchObject({ kind: 'error', error, fullHeight: true }); - expect(multipleAccounts[0]).toMatchObject({ kind: 'error', error, fullHeight: false }); - }); - - it('emits an all-read row for a healthy account with no notifications', () => { - const items = buildAccountItems( - { account, notifications: [], error: null }, - makeContext({ groupBy: GroupBy.DATE }), - ); - - expect(items).toEqual([{ key: `all-read-${accountUUID}`, kind: 'all-read' }]); - }); - - it('delegates to repository grouping when groupBy is REPOSITORY', () => { - const repoNotifications = [ - notification('a-1', 'owner/repo-a', 0), - notification('b-1', 'owner/repo-b', 1), - ]; - - const items = buildAccountItems( - { account, notifications: repoNotifications, error: null }, - makeContext(), - ); - - expect(items.map((item) => item.key)).toEqual([ - `repository-${accountUUID}-owner/repo-a`, - `notification-${accountUUID}:a-1`, - `repository-${accountUUID}-owner/repo-b`, - `notification-${accountUUID}:b-1`, - ]); - }); - }); - describe('NotificationList', () => { it('renders account headers, repository headers and notification rows flattened in order', () => { renderWithProviders( diff --git a/src/renderer/utils/notifications/listItems.test.ts b/src/renderer/utils/notifications/listItems.test.ts new file mode 100644 index 000000000..f51f95eb3 --- /dev/null +++ b/src/renderer/utils/notifications/listItems.test.ts @@ -0,0 +1,265 @@ +import { mockGitHubCloudAccount } from '../../__mocks__/account-mocks'; +import { mockGitifyNotification } from '../../__mocks__/notifications-mocks'; + +import { GroupBy } from '../../types'; + +import { getAccountUUID } from '../auth/utils'; +import { buildAccountItems, buildRepositoryItems, getItemKey, getItemType } from './listItems'; + +type BuildItemsContext = Parameters[1]; + +function makeContext(overrides: Partial = {}): BuildItemsContext { + return { + showAccountHeader: false, + hasMultipleAccounts: false, + collapsedAccounts: new Set(), + collapsedRepositories: new Set(), + animatingNotifications: new Set(), + animatingRepositories: new Map>(), + groupBy: GroupBy.REPOSITORY, + ...overrides, + }; +} + +function notification(id: string, repoFullName: string, order: number) { + return { + ...mockGitifyNotification, + id, + order, + repository: { + ...(mockGitifyNotification.repository as NonNullable< + typeof mockGitifyNotification.repository + >), + fullName: repoFullName, + }, + }; +} + +describe('renderer/utils/notifications/listItems.ts', () => { + describe('buildRepositoryItems', () => { + const accountUUID = getAccountUUID(mockGitHubCloudAccount); + + it('flattens repositories in first-seen order with their expanded notifications', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoB1 = notification('b-1', 'owner/repo-b', 1); + const repoA2 = notification('a-2', 'owner/repo-a', 2); + + const items = buildRepositoryItems(accountUUID, [repoA1, repoB1, repoA2], makeContext()); + + expect(items.map((item) => item.kind)).toEqual([ + 'repository', + 'notification', + 'notification', + 'repository', + 'notification', + ]); + expect(items.map((item) => item.key)).toEqual([ + `repository-${accountUUID}-owner/repo-a`, + `notification-${accountUUID}:a-1`, + `notification-${accountUUID}:a-2`, + `repository-${accountUUID}-owner/repo-b`, + `notification-${accountUUID}:b-1`, + ]); + + const [repoA, , , repoB] = items; + expect(repoA).toMatchObject({ + kind: 'repository', + repoKey: `${accountUUID}-owner/repo-a`, + repoName: 'owner/repo-a', + notifications: [repoA1, repoA2], + }); + expect(repoB).toMatchObject({ kind: 'repository', repoName: 'owner/repo-b' }); + }); + + it('emits only the repository header when the repository is collapsed', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoA2 = notification('a-2', 'owner/repo-a', 1); + + const items = buildRepositoryItems( + accountUUID, + [repoA1, repoA2], + makeContext({ collapsedRepositories: new Set([`${accountUUID}-owner/repo-a`]) }), + ); + + expect(items.map((item) => item.kind)).toEqual(['repository']); + expect(items[0]).toMatchObject({ key: `repository-${accountUUID}-owner/repo-a` }); + }); + + it('marks notifications as animating exit from either animation source', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoA2 = notification('a-2', 'owner/repo-a', 1); + const repoB1 = notification('b-1', 'owner/repo-b', 2); + + const items = buildRepositoryItems( + accountUUID, + [repoA1, repoA2, repoB1], + makeContext({ + animatingNotifications: new Set([`${accountUUID}:a-1`]), + animatingRepositories: new Map([ + [`${accountUUID}-owner/repo-b`, new Set([`${accountUUID}:b-1`])], + ]), + }), + ); + + const animating = items.filter( + (item) => item.kind === 'notification' && item.isAnimatingExit, + ); + expect(animating.map((item) => item.key)).toEqual([ + `notification-${accountUUID}:a-1`, + `notification-${accountUUID}:b-1`, + ]); + }); + + it('returns an empty list when there are no notifications', () => { + expect(buildRepositoryItems(accountUUID, [], makeContext())).toEqual([]); + }); + }); + + describe('buildAccountItems', () => { + const account = mockGitHubCloudAccount; + const accountUUID = getAccountUUID(account); + + it('emits the account header before sorted notification rows', () => { + const outOfOrder = [ + notification('zz', 'owner/repo-a', 2), + notification('aa', 'owner/repo-a', 0), + notification('mm', 'owner/repo-a', 1), + ]; + + const items = buildAccountItems( + { account, notifications: outOfOrder, error: null }, + makeContext({ showAccountHeader: true, groupBy: GroupBy.DATE }), + ); + + expect(items.map((item) => item.key)).toEqual([ + `account-${accountUUID}`, + `notification-${accountUUID}:aa`, + `notification-${accountUUID}:mm`, + `notification-${accountUUID}:zz`, + ]); + expect(items[0]).toMatchObject({ kind: 'account', account, count: 3 }); + }); + + it('marks notification rows as animating exit', () => { + const items = buildAccountItems( + { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, + makeContext({ + groupBy: GroupBy.DATE, + animatingNotifications: new Set([`${accountUUID}:n-1`]), + }), + ); + + expect(items).toEqual([ + { + key: `notification-${accountUUID}:n-1`, + kind: 'notification', + notification: notification('n-1', 'owner/repo-a', 0), + isAnimatingExit: true, + }, + ]); + }); + + it('returns only the account header when the account is collapsed', () => { + const items = buildAccountItems( + { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, + makeContext({ + showAccountHeader: true, + collapsedAccounts: new Set([accountUUID]), + groupBy: GroupBy.DATE, + }), + ); + + expect(items.map((item) => item.kind)).toEqual(['account']); + }); + + it('emits an error row, full height when it is the only account', () => { + const error = { title: 'Account failed', descriptions: ['Try again'], emojis: ['🔥'] }; + const entry: Parameters[0] = { + account, + notifications: [notification('n-1', 'owner/repo-a', 0)], + error, + }; + + const singleAccount = buildAccountItems(entry, makeContext({ groupBy: GroupBy.DATE })); + const multipleAccounts = buildAccountItems( + entry, + makeContext({ hasMultipleAccounts: true, groupBy: GroupBy.DATE }), + ); + + expect(singleAccount.map((item) => item.kind)).toEqual(['error', 'notification']); + expect(singleAccount[0]).toMatchObject({ kind: 'error', error, fullHeight: true }); + expect(multipleAccounts[0]).toMatchObject({ kind: 'error', error, fullHeight: false }); + }); + + it('emits an all-read row for a healthy account with no notifications', () => { + const items = buildAccountItems( + { account, notifications: [], error: null }, + makeContext({ groupBy: GroupBy.DATE }), + ); + + expect(items).toEqual([{ key: `all-read-${accountUUID}`, kind: 'all-read' }]); + }); + + it('delegates to repository grouping when groupBy is REPOSITORY', () => { + const repoNotifications = [ + notification('a-1', 'owner/repo-a', 0), + notification('b-1', 'owner/repo-b', 1), + ]; + + const items = buildAccountItems( + { account, notifications: repoNotifications, error: null }, + makeContext(), + ); + + expect(items.map((item) => item.key)).toEqual([ + `repository-${accountUUID}-owner/repo-a`, + `notification-${accountUUID}:a-1`, + `repository-${accountUUID}-owner/repo-b`, + `notification-${accountUUID}:b-1`, + ]); + }); + }); + + describe('getItemKey / getItemType', () => { + const account = mockGitHubCloudAccount; + const accountUUID = getAccountUUID(account); + + it('exposes the key and kind the virtualizer relies on', () => { + const items = buildAccountItems( + { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, + makeContext({ showAccountHeader: true, groupBy: GroupBy.DATE }), + ); + + expect(items.map(getItemKey)).toEqual([ + `account-${accountUUID}`, + `notification-${accountUUID}:n-1`, + ]); + expect(items.map(getItemType)).toEqual(['account', 'notification']); + }); + + it('reports the discriminant for repository, error and all-read rows', () => { + const repoItems = buildRepositoryItems( + accountUUID, + [notification('a-1', 'owner/repo-a', 0)], + makeContext(), + ); + expect(repoItems.map(getItemType)).toEqual(['repository', 'notification']); + + const errorItems = buildAccountItems( + { + account, + notifications: [], + error: { title: 'Account failed', descriptions: ['Try again'], emojis: ['🔥'] }, + }, + makeContext({ groupBy: GroupBy.DATE }), + ); + expect(errorItems.map(getItemType)).toEqual(['error']); + + const allReadItems = buildAccountItems( + { account, notifications: [], error: null }, + makeContext({ groupBy: GroupBy.DATE }), + ); + expect(allReadItems.map(getItemType)).toEqual(['all-read']); + }); + }); +});