|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * objectstack#27704 — three unread notifications about one topic plus three |
| 6 | + * pending approvals: the header read "4 total · 1 notification + 3 pending |
| 7 | + * approvals" while the one coalesced row read "×3 · 3 unread", so the reader |
| 8 | + * could not tell whether one thing or three were waiting. The badge arithmetic |
| 9 | + * (#7233: unread topics + pending approvals) is unchanged; the header addend |
| 10 | + * now names its unit, and the row it counts is the topic holding the three. |
| 11 | + * |
| 12 | + * Rendered against a REAL i18next instance and the shipped `en` pack, like |
| 13 | + * the sibling `InboxPopover.badgePlural-13133.test.tsx`. |
| 14 | + */ |
| 15 | +import '@testing-library/jest-dom/vitest'; |
| 16 | +import type { ButtonHTMLAttributes, ReactNode } from 'react'; |
| 17 | +import { describe, it, expect, vi } from 'vitest'; |
| 18 | +import { fireEvent, render, screen, within } from '@testing-library/react'; |
| 19 | +import { createI18n, I18nProvider } from '@object-ui/i18n'; |
| 20 | + |
| 21 | +vi.mock('react-router-dom', () => ({ |
| 22 | + useNavigate: () => vi.fn(), |
| 23 | + useParams: () => ({ appName: 'setup' }), |
| 24 | +})); |
| 25 | + |
| 26 | +vi.mock('../../context/NavigationContext', () => ({ |
| 27 | + useNavigationContext: () => ({ currentAppName: 'setup' }), |
| 28 | +})); |
| 29 | + |
| 30 | +type Slot = { children?: ReactNode }; |
| 31 | + |
| 32 | +// Passthrough primitives so the popover body renders without driving Radix |
| 33 | +// open/close in jsdom (the pattern InboxPopover.badgeBreakdown.test.tsx uses). |
| 34 | +vi.mock('@object-ui/components', async (importOriginal) => ({ |
| 35 | + ...(await importOriginal<Record<string, unknown>>()), |
| 36 | + Button: ({ children, ...p }: ButtonHTMLAttributes<HTMLButtonElement>) => ( |
| 37 | + <button type="button" {...p}> |
| 38 | + {children} |
| 39 | + </button> |
| 40 | + ), |
| 41 | + Popover: ({ children }: Slot) => <div>{children}</div>, |
| 42 | + PopoverTrigger: ({ children }: Slot) => <div>{children}</div>, |
| 43 | + PopoverContent: ({ children }: Slot) => <div>{children}</div>, |
| 44 | + Tabs: ({ children }: Slot) => <div>{children}</div>, |
| 45 | + TabsList: ({ children }: Slot) => <div>{children}</div>, |
| 46 | + TabsTrigger: ({ children }: Slot) => <button type="button">{children}</button>, |
| 47 | + TabsContent: ({ children }: Slot) => <div>{children}</div>, |
| 48 | +})); |
| 49 | + |
| 50 | +import { InboxPopover, type InboxNotification } from '../InboxPopover'; |
| 51 | + |
| 52 | +/** Three unread rows of one `(topic, title)` pair: one coalesced group. */ |
| 53 | +const oneTopicThreeUnread: InboxNotification[] = [1, 2, 3].map((i) => ({ |
| 54 | + id: `n${i}`, |
| 55 | + type: 'deal.approved', |
| 56 | + title: 'Deal approved', |
| 57 | + body: `Deal D-${i} was approved`, |
| 58 | + is_read: false, |
| 59 | + created_at: `2026-10-29T10:0${i}:00Z`, |
| 60 | +})); |
| 61 | + |
| 62 | +function renderPopover() { |
| 63 | + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); |
| 64 | + const wrapper = ({ children }: { children: ReactNode }) => ( |
| 65 | + <I18nProvider instance={i18n} persistLanguage={false}> |
| 66 | + {children} |
| 67 | + </I18nProvider> |
| 68 | + ); |
| 69 | + return render( |
| 70 | + <InboxPopover |
| 71 | + notifications={oneTopicThreeUnread} |
| 72 | + unreadCount={oneTopicThreeUnread.length} |
| 73 | + pendingApprovalsCount={3} |
| 74 | + activity={{ value: [], status: 'ready', readable: true }} |
| 75 | + onMarkAllRead={vi.fn()} |
| 76 | + onMarkRead={vi.fn()} |
| 77 | + />, |
| 78 | + { wrapper }, |
| 79 | + ); |
| 80 | +} |
| 81 | + |
| 82 | +describe('InboxPopover — the header breakdown and the grouped row tell one story (objectstack#27704)', () => { |
| 83 | + it('names the unit: 1 unread topic, whose one row holds the 3 unread', () => { |
| 84 | + renderPopover(); |
| 85 | + // #7233 arithmetic untouched: 1 unread topic + 3 pending approvals. |
| 86 | + expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent(/^4 total$/); |
| 87 | + expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^1 unread topic$/); |
| 88 | + expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(/^3 pending approvals$/); |
| 89 | + |
| 90 | + // Exactly one row: the topic the header counted, carrying its 3 unread. |
| 91 | + const row = screen.getByRole('button', { expanded: false }); |
| 92 | + expect(within(row).getByText('Deal approved')).toBeInTheDocument(); |
| 93 | + expect(within(row).getByText('×3')).toBeInTheDocument(); |
| 94 | + expect(row).toHaveTextContent('3 unread'); |
| 95 | + }); |
| 96 | + |
| 97 | + it('the grouped row expands to list each of its records', () => { |
| 98 | + renderPopover(); |
| 99 | + expect(screen.queryByText('Deal D-2 was approved')).not.toBeInTheDocument(); |
| 100 | + fireEvent.click(screen.getByRole('button', { expanded: false })); |
| 101 | + expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument(); |
| 102 | + // D-1 is also the collapsed row's preview line, so it now reads twice. |
| 103 | + expect(screen.getAllByText('Deal D-1 was approved')).toHaveLength(2); |
| 104 | + expect(screen.getByText('Deal D-2 was approved')).toBeInTheDocument(); |
| 105 | + expect(screen.getByText('Deal D-3 was approved')).toBeInTheDocument(); |
| 106 | + }); |
| 107 | +}); |
0 commit comments