diff --git a/components/contacts/__tests__/address-book-picker-dialog.test.tsx b/components/contacts/__tests__/address-book-picker-dialog.test.tsx new file mode 100644 index 000000000..07e571b79 --- /dev/null +++ b/components/contacts/__tests__/address-book-picker-dialog.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { AddressBookPickerDialog } from '../address-book-picker-dialog'; +import type { AddressBook } from '@/lib/jmap/types'; + +// next-intl is mocked globally (t returns the key). +const books = [ + { id: 'b1', name: 'Personal' }, + { id: 'b2', name: 'Work' }, +] as unknown as AddressBook[]; + +describe('AddressBookPickerDialog', () => { + it('says the contacts are being moved, not added', () => { + render(); + expect(screen.getByText('bulk.moving_contacts')).toBeInTheDocument(); + expect(screen.queryByText('bulk.adding_contacts')).toBeNull(); + }); +}); diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 1f763d853..e6c6570a0 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ContactForm } from '../contact-form'; +import { ContactGroupForm } from '../contact-group-form'; import type { ContactCard, ContactMedia } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { findContactPhotoEntry } from '@/stores/contact-store'; @@ -86,6 +87,43 @@ describe('ContactForm', () => { expect(savedData.media).toBeNull(); }); + it('adds a category typed without Enter when the contact is saved, in the spelling of an existing one', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(); + + fireEvent.click(screen.getByText('categories')); + fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'friends, Gym ' } }); + fireEvent.submit(screen.getByText('save').closest('form')!); + + await waitFor(() => expect(onSave).toHaveBeenCalledOnce()); + expect(onSave.mock.calls[0][0].keywords).toEqual({ Friends: true, Gym: true }); + }); + + it('counts a typed category as a change, so leaving asks first', () => { + const onDirtyChange = vi.fn(); + render(); + fireEvent.click(screen.getByText('categories')); + fireEvent.change(screen.getByPlaceholderText('categories_placeholder'), { target: { value: 'Gym' } }); + expect(onDirtyChange).toHaveBeenLastCalledWith(true); + }); + + it.each([ + ['contact form', () => ], + ['group form', () => ], + ])('keeps toasts off the %s footer by registering it as a bottom bar', (_name, form) => { + const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + () => ({ left: 0, top: window.innerHeight - 72, width: 600, height: 72, right: 600, bottom: window.innerHeight, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect, + ); + try { + const { unmount } = render(form()); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe('72px'); + unmount(); + expect(document.documentElement.style.getPropertyValue('--bottom-controls-height')).toBe(''); + } finally { + rect.mockRestore(); + } + }); + describe('photos (P0-3)', () => { // The same PHOTO as ContactCard/get returns it on each version. const photo0_16_23: ContactMedia = { kind: 'photo', uri: 'data:image/jpeg;base64,/9j/', mediaType: 'image/jpeg' }; diff --git a/components/contacts/__tests__/contact-list-item.test.tsx b/components/contacts/__tests__/contact-list-item.test.tsx index d45560265..5f658ac52 100644 --- a/components/contacts/__tests__/contact-list-item.test.tsx +++ b/components/contacts/__tests__/contact-list-item.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { ContactListItem } from '../contact-list-item'; import type { ContactCard } from '@/lib/jmap/types'; @@ -82,4 +82,24 @@ describe('ContactListItem', () => { const { container } = render(); expect(container.querySelector('[data-testid="avatar"]') || container.querySelector('.rounded-full')).toBeNull(); }); + + it('opens the row menu on long-press without leaking the press state onto the row', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.useFakeTimers(); + try { + const onLongPress = vi.fn(); + const { container } = render(); + const row = container.firstElementChild!; + const touchStart = new Event('touchstart', { bubbles: true }); + Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] }); + act(() => { row.dispatchEvent(touchStart); }); + act(() => { vi.advanceTimersByTime(600); }); + expect(onLongPress).toHaveBeenCalledWith(row); + const unknownProp = errors.mock.calls.some((args) => args.some((a) => String(a).includes('isPressed'))); + expect(unknownProp).toBe(false); + } finally { + vi.useRealTimers(); + errors.mockRestore(); + } + }); }); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index 830519bfd..95d65033a 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -42,6 +42,7 @@ function getRegisteredGroups(): CommandGroup[] { import { useAuthStore } from '@/stores/auth-store'; import { useContactStore } from '@/stores/contact-store'; import { useToastStore } from '@/stores/toast-store'; +import { interceptLeave } from '@/lib/leave-guard'; export const alice: ContactCard = { id: 'c-alice', @@ -131,6 +132,44 @@ describe('ContactsApp', () => { expect(useContactStore.getState().selectedContactId).toBe('c-bob'); }); + it('adds the open contact to the selection on Ctrl+click, as Files does', () => { + renderContacts(); + fireEvent.click(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByText('Bob Jones'), { ctrlKey: true }); + expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-alice', 'c-bob']); + }); + + it('selects from the open contact on Shift+click', () => { + const carol: ContactCard = { ...bob, id: 'c-carol', name: { full: 'Carol King' }, emails: { e0: { address: 'carol@example.com' } } }; + renderContacts([alice, bob, carol]); + fireEvent.click(screen.getByText('Bob Jones')); + fireEvent.click(screen.getByText('Carol King'), { shiftKey: true }); + expect([...useContactStore.getState().selectedContactIds].sort()).toEqual(['c-bob', 'c-carol']); + }); + + it('has the rail and tab bar ask before they leave a contact form with changes', async () => { + renderContacts(); + fireEvent.click(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ })); + fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } }); + + // A rail link wired as the real one is: interceptLeave first. + const navigated = vi.fn(); + const link = document.createElement('a'); + link.addEventListener('click', (e) => { if (!interceptLeave(e)) navigated(); }); + document.body.appendChild(link); + try { + act(() => { link.click(); }); + const dialog = await screen.findByRole('alertdialog'); + expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument(); + expect(navigated).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'form.discard' })); + await waitFor(() => expect(navigated).toHaveBeenCalledOnce()); + } finally { + link.remove(); + } + }); + it('leaves an untouched contact form without asking', () => { renderContacts(); fireEvent.click(screen.getByText('Alice Smith')); diff --git a/components/contacts/__tests__/contacts-delete-confirm.test.tsx b/components/contacts/__tests__/contacts-delete-confirm.test.tsx new file mode 100644 index 000000000..54fc25d59 --- /dev/null +++ b/components/contacts/__tests__/contacts-delete-confirm.test.tsx @@ -0,0 +1,113 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The delete confirms must name what goes (R3), so this file's t() shows the +// values it was given: "delete_named_title {"name":"Alice Smith"}". +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => + values ? `${key} ${JSON.stringify(values)}` : key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), back: vi.fn() }), + useParams: () => ({ locale: 'en' }), + usePathname: () => '/en/contacts', + useSearchParams: () => new URLSearchParams(), +})); +vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null })); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () =>