diff --git a/components/__tests__/keyboard-shortcuts-sections.test.tsx b/components/__tests__/keyboard-shortcuts-sections.test.tsx index 08610f89b..2fabee777 100644 --- a/components/__tests__/keyboard-shortcuts-sections.test.tsx +++ b/components/__tests__/keyboard-shortcuts-sections.test.tsx @@ -9,6 +9,10 @@ vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({ CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }], })); +// Apps without keys get no tab; Contacts and Files are left empty here for that. +vi.mock('@/lib/keyboard-shortcuts/contacts', () => ({ CONTACTS_SECTIONS: [] })); +vi.mock('@/lib/keyboard-shortcuts/files', () => ({ FILES_SECTIONS: [] })); + const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal'); describe('keyboard shortcuts help per app', () => { diff --git a/components/contacts/__tests__/contact-export.test.ts b/components/contacts/__tests__/contact-export.test.ts index 1106e5bcc..db0734444 100644 --- a/components/contacts/__tests__/contact-export.test.ts +++ b/components/contacts/__tests__/contact-export.test.ts @@ -124,3 +124,12 @@ describe('vCard export of contact photos', () => { expect(await exported!.text()).toContain('FN:Contact 1'); }); }); + +describe('vCard file names', () => { + it('keep letters of any script instead of turning them into underscores', async () => { + const { sanitizeFilename } = await import('../contact-export'); + expect(sanitizeFilename('Jürgen Müller')).toBe('Jürgen_Müller'); + expect(sanitizeFilename('李 雷')).toBe('李_雷'); + expect(sanitizeFilename('a/b:c')).toBe('a_b_c'); + }); +}); diff --git a/components/contacts/__tests__/contact-form.test.tsx b/components/contacts/__tests__/contact-form.test.tsx index 3ad1693e4..1f763d853 100644 --- a/components/contacts/__tests__/contact-form.test.tsx +++ b/components/contacts/__tests__/contact-form.test.tsx @@ -44,7 +44,7 @@ describe('ContactForm', () => { render(); fireEvent.submit(screen.getByText('save').closest('form')!); await waitFor(() => { - expect(screen.getByText('name_required')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('name_required'); }); expect(onSave).not.toHaveBeenCalled(); }); @@ -460,7 +460,7 @@ describe('ContactForm', () => { fireEvent.submit(screen.getByText('save').closest('form')!); await waitFor(() => { - expect(screen.getByText('name_required')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('name_required'); }); expect(onSave).not.toHaveBeenCalled(); }); @@ -513,4 +513,15 @@ describe('ContactForm', () => { }); expect(onSave.mock.calls[0][0].kind).toBe('individual'); }); + + it("names the account of an address book once, not twice", () => { + const books = [ + { id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true }, + { id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true }, + ] as never; + render(); + const options = screen.getAllByRole('option').map((o) => o.textContent); + expect(options).toContain('Stalwart Address Book (usera@example.org)'); + expect(options).toContain('Team (userb@example.org)'); + }); }); diff --git a/components/contacts/__tests__/contact-list.test.tsx b/components/contacts/__tests__/contact-list.test.tsx index 2f1c9c8db..16fdd839f 100644 --- a/components/contacts/__tests__/contact-list.test.tsx +++ b/components/contacts/__tests__/contact-list.test.tsx @@ -183,4 +183,75 @@ describe('ContactList', () => { }); }); + + it("writes to a contact from the right-click menu in the app's composer, not through mailto:", () => { + const onComposeContact = vi.fn(); + const href = window.location.href; + render(); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByText('context_menu.send_email')); + expect(onComposeContact).toHaveBeenCalledWith(alice); + expect(window.location.href).toBe(href); + }); + + it("moves a contact to another address book from its right-click menu, and the selection from the bulk bar", () => { + const onMoveContacts = vi.fn(); + const { unmount } = render(); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + fireEvent.click(screen.getByText('address_books.move_to')); + expect(onMoveContacts).toHaveBeenCalledWith(['1']); + unmount(); + + render(); + fireEvent.click(screen.getByRole('button', { name: /address_books.move_to/ })); + expect(onMoveContacts).toHaveBeenLastCalledWith(['1', '2']); + }); + + it("moves through the list with the arrow keys and opens the contact, like Mail and Files", () => { + const onSelectContact = vi.fn(); + render(); + const rows = screen.getAllByRole('option'); + expect(rows[0]).toHaveAttribute('tabindex', '0'); + expect(rows[1]).toHaveAttribute('tabindex', '-1'); + rows[0].focus(); + fireEvent.keyDown(rows[0], { key: 'ArrowDown' }); + expect(onSelectContact).toHaveBeenLastCalledWith('2'); + expect(document.activeElement).toBe(screen.getAllByRole('option')[1]); + }); + + it("selects all with Ctrl+A, deletes with Delete, clears with Escape, and searches with /", () => { + const onSelectAll = vi.fn(); + const onBulkDelete = vi.fn(); + const onClearSelection = vi.fn(); + const { rerender } = render(); + const row = screen.getAllByRole('option')[0]; + fireEvent.keyDown(row, { key: 'a', code: 'KeyA', ctrlKey: true }); + expect(onSelectAll).toHaveBeenCalledWith(['1', '2']); + + rerender(); + fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Delete' }); + expect(onBulkDelete).toHaveBeenCalled(); + fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Escape' }); + expect(onClearSelection).toHaveBeenCalled(); + + fireEvent.keyDown(document.body, { key: '/' }); + expect(document.activeElement).toBe(screen.getByPlaceholderText('search_placeholder')); + }); + + it("opens a contact's menu on long-press, with Select to start selecting", () => { + vi.useFakeTimers(); + try { + const onToggleSelection = vi.fn(); + render(); + const row = screen.getAllByRole('option')[0]; + const touchStart = new Event('touchstart', { bubbles: true }); + Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] }); + act(() => { row.dispatchEvent(touchStart); }); + act(() => { vi.advanceTimersByTime(600); }); + fireEvent.click(screen.getByText('filters.select')); + expect(onToggleSelection).toHaveBeenCalledWith('1'); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/components/contacts/__tests__/contact-print.test.ts b/components/contacts/__tests__/contact-print.test.ts index 9a6ced8ef..e229e4100 100644 --- a/components/contacts/__tests__/contact-print.test.ts +++ b/components/contacts/__tests__/contact-print.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { printContact } from '../contact-print'; +import { printContact, printLabels } from '../contact-print'; import { useAuthStore } from '@/stores/auth-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import type { ContactCard } from '@/lib/jmap/types'; +const LABELS = printLabels((key) => key, 'en'); + const base: ContactCard = { id: 'c1', addressBookIds: {}, @@ -36,7 +38,7 @@ describe('printContact', () => { it('opens the window right away and prints a Stalwart 1.0 photo inline as a data: URI', async () => { fetchBlob.mockResolvedValue(new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' })); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }, LABELS); // Before anything is awaited, while the click still allows a popup. expect(open).toHaveBeenCalledTimes(1); await done; @@ -47,7 +49,7 @@ describe('printContact', () => { }); it('prints a Stalwart 0.16 photo as it is', async () => { - await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }); + await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }, LABELS); expect(fetchBlob).not.toHaveBeenCalled(); expect(written()).toContain('src="data:image/jpeg;base64,/9j/"'); @@ -57,7 +59,7 @@ describe('printContact', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404')); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }, LABELS); await vi.advanceTimersByTimeAsync(1000); await done; @@ -68,9 +70,21 @@ describe('printContact', () => { it('does not write into a window the user already closed', async () => { fetchBlob.mockResolvedValue(new Blob(['x'], { type: 'image/jpeg' })); - const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }); + const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }, LABELS); printWindow.closed = true; await done; expect(printWindow.document.write).not.toHaveBeenCalled(); }); + + it('prints the headings and dates in the language of the app', async () => { + const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record)[key] ?? key, 'de'); + await printContact({ + ...base, + emails: { e: { address: 'ann@example.com' } }, + anniversaries: { b: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } }, + } as ContactCard, de); + expect(written()).toContain('

E-Mail-Adressen

'); + expect(written()).toContain('
Geburtstag
15. März 1988
'); + expect(written()).not.toContain('>Email<'); + }); }); diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx new file mode 100644 index 000000000..830519bfd --- /dev/null +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -0,0 +1,255 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// The whole Contacts app in local mode (no JMAP client): the stores hold the +// contacts, and the shell around it is stubbed out. + +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: () =>