import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import router from '@/router' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import type { Article } from '@/composables/useArticles' import { articleOf, bylines, callsTo, createMetaBackend, failWith, mountMeta, renewSites, textOf, typeInto, unmountMeta, type MetaBackend, } from './metaTestKit' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) const SEARCH_WAIT_MS = 320 beforeEach(() => { localStorage.clear() }) afterEach(unmountMeta) const FIVE = ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine'] function backendWith(sites: 'one' | 'several', articles: Article[]): MetaBackend { const backend = createMetaBackend({ sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(), }) backend.articles = articles return backend } function withAuthors(id: number, names: string[], overrides: Partial
= {}) { return articleOf(id, `Article ${id}`, { authors: bylines(...names), ...overrides, }) } function bodyRows() { return Array.from(document.body.querySelectorAll('tbody tr')) } function cell(row: HTMLElement, index: number) { return row.children[index] as HTMLElement } function headers() { return Array.from(document.body.querySelectorAll('thead th')).map(textOf) } function siteSelect() { return document.body.querySelector('button[aria-label="Site"]') } function listCalls(backend: MetaBackend) { return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format')) } async function chooseSite(label: string) { siteSelect()!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item) === label)! .click() await flushPromises() } function currentQuery() { return { ...router.currentRoute.value.query } } describe('Articles: the Authors column on every frame', () => { it.each([ { authors: 'no authors', names: [], avatars: 0, more: '', text: '—' }, { authors: 'one author', names: FIVE.slice(0, 1), avatars: 1, more: '', text: '', }, { authors: 'three', names: FIVE.slice(0, 3), avatars: 3, more: '', text: '', }, { authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' }, ])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => { await mountMeta('articles', '/article', { backend: backendWith('one', [withAuthors(1, names)]), }) const authorsCell = cell(bodyRows()[0]!, 1) const stack = authorsCell.querySelector('[data-testid="author-stack"]') expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars) expect( authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '', ).toBe(more) if (text) { expect(textOf(authorsCell)).toBe(text) expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull() } expect(headers()[1]).toBe('Authors') expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', ')) }) it('draws avatars with the first name and no site on the mobile cards', async () => { await mountMeta('articles', '/article', { width: MOBILE_WIDTH, backend: backendWith('several', [ withAuthors(1, FIVE.slice(0, 3), { site_id: 2 }), withAuthors(2, []), ]), }) const cards = Array.from( document.body.querySelectorAll('li [data-row-title]'), ).map((title) => title.closest('li') as HTMLElement) expect(textOf(cards[0])).toContain('Inès Duarte +2') expect( cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'), ).toHaveLength(3) expect(textOf(cards[1])).toContain('No authors') expect(textOf(cards[0])).not.toContain('Journal') }) }) describe('Articles: the Site column and the Site filter', () => { it('has neither with one site', async () => { await mountMeta('articles', '/article', { backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]), }) expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', '']) expect(siteSelect()).toBeNull() expect(textOf(document.body)).not.toContain('All sites') }) it.each(['owner', 'admin', 'verified'] as const)( 'shows a read-only Site column and a Site select for a %s with several sites', async (role) => { const backend = backendWith('several', [ withAuthors(1, ['Inès Duarte'], { site_id: 2 }), withAuthors(2, ['Inès Duarte'], { site_id: 1 }), ]) backend.me.role = role === 'verified' ? undefined : role await mountMeta('articles', '/article', { backend }) expect(headers()).toEqual([ 'Title', 'Authors', 'Site', 'Status', 'Created', 'Updated', '', ]) expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew']) expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull() siteSelect()!.click() await flushPromises() expect( Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf), ).toEqual(['All sites', 'Renew', 'Journal', 'Projects', 'Archive']) }, ) it('drops the Site column and filter when the sites cannot be loaded', async () => { const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])]) backend.responders['GET /v1/site/'] = failWith(500) await mountMeta('articles', '/article', { backend }) expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', '']) expect(siteSelect()).toBeNull() expect(document.body.querySelector('[role="alert"]')).toBeNull() }) }) describe('Articles: choosing a site', () => { function journalAndRenew(count: number) { return Array.from({ length: count }, (_, index) => withAuthors(index + 1, ['Inès Duarte'], { site_id: index % 2 === 0 ? 2 : 1, is_online: index % 3 !== 0, }), ) } it('puts site=journal in the URL without the page, and asks for that site with the same q, status and sort', async () => { const backend = backendWith('several', journalAndRenew(40)) await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', { backend, }) const before = textOf(document.body.querySelector('[role="tablist"]')) await chooseSite('Journal') expect(currentQuery()).toEqual({ site: 'journal', size: '5', status: 'online', sort: 'title:asc', }) const last = listCalls(backend).at(-1)! expect(last.query.get('site')).toBe('journal') expect(last.query.get('page')).toBe('1') expect(last.query.get('status')).toBe('online') expect(last.query.get('sort')).toBe('title:asc') expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before) expect(siteSelect()!.textContent).toContain('Journal') }) it('goes back to the full list and page 1 with All sites', async () => { const backend = backendWith('several', journalAndRenew(40)) await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend, }) await chooseSite('All sites') expect(currentQuery()).toEqual({ size: '5' }) const last = listCalls(backend).at(-1)! expect(last.query.has('site')).toBe(false) expect(last.query.get('page')).toBe('1') }) it.each(['__all', 'all'])('filters by a real site whose key is %s', async (key) => { const backend = backendWith('several', journalAndRenew(4)) backend.sites = backend.sites.map((site) => site.key === 'journal' ? { ...site, key } : site, ) await mountMeta('articles', '/article', { backend }) await chooseSite('Journal') expect(currentQuery()).toEqual({ site: key }) expect(listCalls(backend).at(-1)!.query.get('site')).toBe(key) expect(siteSelect()!.textContent).toContain('Journal') }) it('reads ?site=journal&page=2 on arrival and shows Journal in the filter', async () => { const backend = backendWith('several', journalAndRenew(40)) await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend, }) expect(siteSelect()!.textContent).toContain('Journal') const first = listCalls(backend)[0]! expect(first.query.get('site')).toBe('journal') expect(first.query.get('page')).toBe('2') }) it.each([ { query: '?site=nope', sites: 'several' }, { query: '?site=renew', sites: 'one' }, ] as const)('drops $query silently with $sites site(s)', async ({ query, sites }) => { const backend = backendWith(sites, journalAndRenew(8)) await mountMeta('articles', `/article${query}`, { backend }) expect(currentQuery()).toEqual({}) expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true) expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull() expect(bodyRows().length).toBeGreaterThan(0) }) it.each([ { action: 'searches for "plaster"', act: async () => { await typeInto( document.body.querySelector('input[type="search"]')!, 'plaster', ) await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS)) await flushPromises() }, }, { action: 'changes the tab', act: async () => { const tab = Array.from( document.body.querySelectorAll('[role="tab"]'), ).find((item) => textOf(item).startsWith('Drafts'))! tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) tab.click() await flushPromises() }, }, { action: 'changes the sort', act: async () => { Array.from(document.body.querySelectorAll('thead button')) .find((item) => textOf(item).startsWith('Title'))! .click() await flushPromises() }, }, { action: 'goes to page 2', act: async () => { document.body.querySelector('button[aria-label="Next page"]')!.click() await flushPromises() }, }, { action: 'changes the page size', act: async () => { document.body.querySelector('[aria-label="Rows per page"]')!.click() await flushPromises() Array.from(document.body.querySelectorAll('[role="option"]')) .find((item) => textOf(item) === '50 per page')! .click() await flushPromises() }, }, ])('keeps ?site=journal when the user $action', async ({ act }) => { const backend = backendWith('several', journalAndRenew(40)) await mountMeta('articles', '/article?site=journal&size=5', { backend }) await act() expect(currentQuery().site).toBe('journal') expect(listCalls(backend).at(-1)!.query.get('site')).toBe('journal') }) it('shows the empty state of a site and returns to the full list with Show all sites', async () => { const backend = backendWith('several', journalAndRenew(6)) await mountMeta('articles', '/article?site=archive', { backend }) const text = textOf(document.body) expect(text).toContain('No articles on "Archive"') expect(text).toContain('Move articles here from the editor, or choose another site.') Array.from(document.body.querySelectorAll('button')) .find((item) => textOf(item) === 'Show all sites')! .click() await flushPromises() expect(currentQuery()).toEqual({}) expect(listCalls(backend).at(-1)!.query.has('site')).toBe(false) expect(bodyRows().length).toBeGreaterThan(0) }) it('keeps the existing No results and No drafts states when a search or a tab is active on a site', async () => { const backend = backendWith('several', journalAndRenew(6)) await mountMeta('articles', '/article?site=archive&q=tiles', { backend, }) expect(textOf(document.body)).toContain('No articles match "tiles"') expect(textOf(document.body)).not.toContain('No articles on "Archive"') await unmountMeta() await mountMeta('articles', '/article?site=archive&status=draft', { backend, }) expect(textOf(document.body)).toContain('No drafts') expect(textOf(document.body)).not.toContain('No articles on "Archive"') }) })