diff --git a/client/src/components/Collections/CollectionLabelFilter.test.tsx b/client/src/components/Collections/CollectionLabelFilter.test.tsx new file mode 100644 index 000000000..94ab5d594 --- /dev/null +++ b/client/src/components/Collections/CollectionLabelFilter.test.tsx @@ -0,0 +1,121 @@ +// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006 +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import type { TranslationFn } from '../../types'; +import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter'; + +const t = ((key: string) => key) as unknown as TranslationFn; +const LABELS = [ + { id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 }, + { id: 2, name: 'Heritage', color: null, count: 0 }, +] as unknown as LabelOption[]; + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +afterEach(() => vi.restoreAllMocks()); + +/** The labels need `need` px against a slot of `room` px beside the map's controls. */ +function room(need: number, room: number) { + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) { + return this.classList.contains('col-labelfilter-measure') ? need : 0; + }); + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) { + return this.classList.contains('col-labelfilter-slot') ? room : 0; + }); +} + +function setup(props: Partial[0]> = {}) { + const onLabelFilter = vi.fn(); + const onManageLabels = vi.fn(); + const view = render( + + ); + return { ...view, onLabelFilter, onManageLabels }; +} + +describe('CollectionLabelFilter', () => { + it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => { + const on = setup(); + fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ })); + expect(on.onLabelFilter).toHaveBeenCalledWith([1]); + on.unmount(); + + const off = setup({ labelFilter: [1, 2] }); + expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ })); + expect(off.onLabelFilter).toHaveBeenCalledWith([2]); + }); + + it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => { + const empty = setup({ labelOptions: [] }); + fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' })); + expect(empty.onManageLabels).toHaveBeenCalled(); + empty.unmount(); + + setup(); + expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument(); + }); + + it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => { + room(300, 500); + setup({ variant: 'map' }); + expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull(); + }); + + it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => { + room(600, 200); + const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] }); + const fold = screen.getByRole('button', { name: /collections.labels.title/ }); + expect(fold).toHaveTextContent('1'); + expect(fold).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(fold); + const list = screen.getByRole('group', { name: 'collections.labels.title' }); + expect(list).toHaveTextContent('Core shortlist'); + expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ })); + expect(onLabelFilter).toHaveBeenCalledWith([2, 1]); + }); + + it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => { + room(600, 200); + const { onManageLabels } = setup({ variant: 'map' }); + const fold = screen.getByRole('button', { name: /collections.labels.title/ }); + + fireEvent.click(fold); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull(); + + fireEvent.click(fold); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull(); + + fireEvent.click(fold); + fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ })); + expect(onManageLabels).toHaveBeenCalled(); + expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull(); + }); + + it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => { + room(600, 200); + setup({ variant: 'map' }); + fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ })); + fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' })); + expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Collections/CollectionPlaceTags.test.tsx b/client/src/components/Collections/CollectionPlaceTags.test.tsx new file mode 100644 index 000000000..af7141e38 --- /dev/null +++ b/client/src/components/Collections/CollectionPlaceTags.test.tsx @@ -0,0 +1,88 @@ +// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005 +import type { CollectionLabel, CollectionPlace } from '@trek/shared'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../tests/helpers/render'; +import CollectionPlaceTags from './CollectionPlaceTags'; + +// jsdom lays nothing out: the observer reports each element once it is observed, and +// the widths below stand in for the browser's. +beforeAll(() => { + globalThis.ResizeObserver = class { + constructor(private readonly cb: ResizeObserverCallback) {} + observe(target: Element) { + this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver); + } + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +afterEach(() => vi.restoreAllMocks()); + +/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */ +function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + if (this.classList.contains('col-lrow-cat')) return 50; + if (this.classList.contains('col-lrow-label')) return 100; + return 0; + }); + vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) { + if (this.classList.contains('col-lrow-tags')) return row; + if (this.classList.contains('col-lrow-txt')) return 60; + return 0; + }); + vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) { + if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60; + return 0; + }); +} + +const label = (id: number, name: string): CollectionLabel => + ({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel; +const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) => + ({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace; +const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')]; + +describe('CollectionPlaceTags', () => { + it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => { + layout(); + const { container } = render(); + const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className); + expect(chips[0]).toBe('col-lrow-cat'); + expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1); + expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2); + expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => { + layout({ row: 800 }); + const { container } = render(); + expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3); + expect(container.querySelector('.col-lrow-more')).toBeNull(); + }); + + it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => { + layout(); + render(); + fireEvent.mouseEnter(screen.getByText('+2')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop'); + }); + + it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => { + layout({ row: 800, cutName: 'Heritage' }); + render(); + fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement); + await new Promise((r) => setTimeout(r, 350)); + expect(screen.queryByRole('tooltip')).toBeNull(); + fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement); + + fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage'); + }); +});