diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx new file mode 100644 index 00000000000..e0052a4d606 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx @@ -0,0 +1,118 @@ +import { screen, within } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { statsText } from '../../../localization/stats'; +import { requireContext } from '../../../tests/helpers'; +import { defaultLayoutGenerated } from '../StatsSpec'; +import { mockStatsRequests, renderEmptyStatsPage } from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +type User = Awaited>['user']; + +const defaultPageName = defaultLayoutGenerated[0].label; +const newPageName = 'My Shared Page'; + +const getSection = (sectionName: string): HTMLElement => + screen.getByRole('heading', { name: sectionName }).closest('li')!; + +const getPageButtons = (sectionName: string) => + within(getSection(sectionName)) + .getAllByRole('listitem') + .map((item) => within(item).getAllByRole('button')[0]); + +const getPageNames = (sectionName: string) => + getPageButtons(sectionName).map(({ textContent }) => textContent); + +const getAddPageButton = () => + within(getSection(statsText.shared())).getByRole('button', { + name: commonText.add(), + }); + +const getEditButton = (): HTMLElement => + screen + .getAllByRole('button', { name: commonText.edit() }) + .find(({ textContent }) => textContent === commonText.edit())!; + +async function openAddPageDialog(user: User): Promise { + await user.click(getEditButton()); + await user.click(getAddPageButton()); + return screen.getByRole('dialog', { name: statsText.addPage() }); +} + +async function addSharedPage(user: User): Promise { + const dialog = await openAddPageDialog(user); + await user.type(within(dialog).getByRole('textbox'), newPageName); + await user.click( + within(dialog).getByRole('button', { name: commonText.add() }) + ); +} + +describe('Adding a new Shared page', () => { + test('"+" opens an empty "Add Page" dialog', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openAddPageDialog(user); + expect(within(dialog).getByRole('textbox')).toHaveValue(''); + }); + + test('adds an empty page to the end of Shared and opens it', async () => { + const { user, getSharedLayout, getPersonalLayout } = + await renderEmptyStatsPage(); + const personalLayout = getPersonalLayout(); + await addSharedPage(user); + + expect( + screen.queryByRole('dialog', { name: statsText.addPage() }) + ).not.toBeInTheDocument(); + expect(getPageNames(statsText.shared())).toEqual([ + defaultPageName, + newPageName, + ]); + expect(getPageButtons(statsText.shared())[1]).toHaveAttribute( + 'aria-current', + 'page' + ); + + expect(getSharedLayout()?.[1]).toMatchObject({ + label: newPageName, + categories: [], + }); + defaultLayoutGenerated[0].categories.forEach(({ label }) => + expect( + screen.queryByRole('heading', { name: label }) + ).not.toBeInTheDocument() + ); + expect( + screen.getByRole('button', { name: statsText.addACategory() }) + ).toBeInTheDocument(); + + expect(getPersonalLayout()).toEqual(personalLayout); + }); + + test('Cancel discards the new page and reopens the last existing page', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addSharedPage(user); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + expect(getSharedLayout()?.map(({ label }) => label)).toEqual([ + defaultPageName, + ]); + expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); + expect(getPageButtons(statsText.shared())[0]).toHaveAttribute( + 'aria-current', + 'page' + ); + }); + + test('closing the dialog does not add a page', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openAddPageDialog(user); + await user.type(within(dialog).getByRole('textbox'), newPageName); + await user.click( + within(dialog).getByRole('button', { name: commonText.close() }) + ); + + expect(getSharedLayout()).toHaveLength(1); + expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); + }); +}); \ No newline at end of file diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx new file mode 100644 index 00000000000..fcc26f3a6f5 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx @@ -0,0 +1,105 @@ +import { screen } from '@testing-library/react'; +import { statsText } from '../../../localization/stats'; +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { defaultLayoutGenerated } from '../StatsSpec'; +import type { StatLayout } from '../types'; +import { mockStatsRequests, renderEmptyStatsPage } from './statsPageTestUtils'; +requireContext(); +mockStatsRequests(); + +const findCategory = (layout: RA, label: string) => + layout[0].categories.find((category) => category.label === label); + +describe('Statistics initial load with no saved layout', () => { + test('saves the default shared page to collection preferences', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + expect(sharedLayout).toHaveLength(1); + expect(sharedLayout[0].label).toBe(defaultLayoutGenerated[0].label); + expect(sharedLayout[0].categories.map(({ label }) => label)).toEqual( + defaultLayoutGenerated[0].categories.map(({ label }) => label) + ); + }); + + test('saves the default personal page to user preferences without loading it', async () => { + const { getPersonalLayout } = await renderEmptyStatsPage(); + expect(getPersonalLayout()).toEqual([defaultLayoutGenerated[1]]); + }); + + test('marks the active page as refreshed', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + expect( + Number.isNaN(new Date(sharedLayout[0].lastUpdated!).valueOf()) + ).toBe(false); + expect( + screen.getByText(statsText.lastRefreshed(), { exact: false }) + ).toBeInTheDocument(); + }); + + test('renders the Shared and Private sections with their default pages', async () => { + await renderEmptyStatsPage(); + expect( + screen.getByRole('heading', { name: statsText.shared() }) + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: statsText.private() }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: defaultLayoutGenerated[0].label }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: defaultLayoutGenerated[1].label }) + ).toBeInTheDocument(); + }); + + test('renders every default category of the shared page', async () => { + await renderEmptyStatsPage(); + defaultLayoutGenerated[0].categories.forEach(({ label }) => + expect(screen.getByRole('heading', { name: label })).toBeInTheDocument() + ); + }); + + test('fills backend categories from the backend response', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + const preparations = findCategory( + sharedLayout, + defaultLayoutGenerated[0].categories[1].label + ); + expect( + preparations?.items.map(({ label, itemValue }) => [label, itemValue]) + ).toEqual([ + ['C&S', '826 / 430'], + ['EtOH', '176 / 985'], + ]); + expect(screen.getByText('C&S')).toBeInTheDocument(); + expect(screen.getByText('826 / 430')).toBeInTheDocument(); + }); + + test('fills backend percentage stats from the backend response', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + const values = Object.fromEntries( + sharedLayout[0].categories + .flatMap(({ items }) => items) + .map((item) => [item.label, item.itemValue]) + ); + expect(values[statsText.percentGeoReferenced()]).toBe('42%'); + expect(values[statsText.percentImaged()]).toBe('17%'); + expect(screen.getByText('42%')).toBeInTheDocument(); + expect(screen.getByText('17%')).toBeInTheDocument(); + }); + + test('fills dynamic categories from the query results', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + const typeSpecimens = findCategory( + sharedLayout, + statsText.typeSpecimens() + ); + expect(typeSpecimens?.items.map(({ label }) => label)).toEqual([ + 'Holotype', + 'Neotype', + ]); + typeSpecimens?.items.forEach((item) => + expect(item.itemValue).toBeDefined() + ); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/initialLoad.test.ts b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/initialLoad.test.ts new file mode 100644 index 00000000000..64e4c725bee --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/initialLoad.test.ts @@ -0,0 +1,350 @@ +import { f } from '../../../utils/functools'; +import type { RA } from '../../../utils/types'; +import { formatNumber } from '../../Atoms/Internationalization'; +import { MINUTE } from '../../Atoms/timeUnits'; +import { + applyRefreshLayout, + applyStatBackendResponse, + getBackendUrlToFetch, + getDynamicQuerySpecsToFetch, + resolveStatsSpec, + setLayoutUndefined, +} from '../hooks'; +import { + backEndStatsSpec, + defaultLayoutGenerated, + dynamicStatsSpec, + generateDefaultLayout, + statsSpec, +} from '../StatsSpec'; +import type { CustomStat, DefaultStat, StatLayout } from '../types'; +import { defaultLayoutTest, statsSpecTest } from './layout.tests'; + +const allItems = (layout: RA): RA => + layout.flatMap(({ categories }) => categories.flatMap(({ items }) => items)); + +describe('generateDefaultLayout', () => { + test('creates one page per stats source, in spec order', () => { + expect(defaultLayoutTest.map(({ label }) => label)).toEqual( + Object.values(statsSpecTest).map(({ sourceLabel }) => sourceLabel) + ); + }); + + test('creates one category per spec category, in spec order', () => { + Object.values(statsSpecTest).forEach(({ categories }, pageIndex) => + expect( + defaultLayoutTest[pageIndex].categories.map(({ label }) => label) + ).toEqual(Object.values(categories).map(({ label }) => label)) + ); + }); + + test('a fresh layout has no values and has never been updated', () => { + defaultLayoutTest.forEach((page) => + expect(page.lastUpdated).toBeUndefined() + ); + allItems(defaultLayoutTest).forEach((item) => + expect(item.itemValue).toBeUndefined() + ); + }); + + test('maps spec items to DefaultStat items', () => { + expect(defaultLayoutTest[0].categories[2].items).toEqual([ + { + type: 'DefaultStat', + pageName: 'collection', + categoryName: 'locality_geography', + itemName: 'countries', + label: statsSpecTest.collection.categories.locality_geography.items + .countries.label, + itemValue: undefined, + itemType: 'BackEndStat', + pathToValue: 'countries', + }, + ]); + }); + + test('only BackEndStat items carry a pathToValue', () => { + allItems(defaultLayoutTest).forEach((item) => { + if (item.type === 'DefaultStat' && item.itemType !== 'BackEndStat') + expect(item.pathToValue).toBeUndefined(); + }); + }); + + test('an empty spec produces an empty layout', () => { + expect(generateDefaultLayout({})).toEqual([]); + }); +}); + +describe('defaultLayoutGenerated (used when no layout preference exists)', () => { + test('has a shared page and a personal page', () => { + expect(defaultLayoutGenerated).toHaveLength(2); + expect(defaultLayoutGenerated[0].label).toBe( + statsSpec.collection.sourceLabel + ); + expect(defaultLayoutGenerated[1].label).toBe(statsSpec.user.sourceLabel); + }); + + test('every page has at least one category and every category has items', () => { + defaultLayoutGenerated.forEach(({ categories }) => { + expect(categories.length).toBeGreaterThan(0); + categories.forEach(({ items }) => + expect(items.length).toBeGreaterThan(0) + ); + }); + }); + + test('starts with no values and no lastUpdated', () => { + defaultLayoutGenerated.forEach((page) => + expect(page.lastUpdated).toBeUndefined() + ); + allItems(defaultLayoutGenerated).forEach((item) => + expect(item.itemValue).toBeUndefined() + ); + }); + + test('contains only DefaultStat items', () => { + allItems(defaultLayoutGenerated).forEach((item) => + expect(item.type).toBe('DefaultStat') + ); + }); + + test('every non-dynamic default item resolves to a fetchable spec', () => { + allItems(defaultLayoutGenerated) + .filter( + (item): item is DefaultStat => + item.type === 'DefaultStat' && item.itemType !== 'DynamicStat' + ) + .forEach((item) => + expect( + resolveStatsSpec(item, { showPreparationsTotal: true }) + ).toBeDefined() + ); + }); +}); + +describe('fetches needed on initial load', () => { + test('requests every backend category on the default shared page', () => { + const sharedPage = [defaultLayoutGenerated[0]]; + expect(getBackendUrlToFetch(sharedPage)).toEqual( + backEndStatsSpec + .map(({ responseKey }) => responseKey) + .filter((key) => key.startsWith('/stats/collection/')) + .filter((key) => !key.startsWith('/stats/collection/user/')) + ); + }); + + test('requests each backend category only once', () => { + const urls = getBackendUrlToFetch([ + ...defaultLayoutGenerated, + ...defaultLayoutGenerated, + ]); + expect(urls).toEqual(Array.from(new Set(urls))); + }); + + test('every requested backend URL is known to the backend stats spec', () => { + const knownKeys = backEndStatsSpec.map(({ responseKey }) => responseKey); + getBackendUrlToFetch(defaultLayoutGenerated).forEach((url) => + expect(knownKeys).toContain(url) + ); + }); + + test('runs every dynamic query in the default layout', () => { + expect( + getDynamicQuerySpecsToFetch(defaultLayoutGenerated).map(({ key }) => key) + ).toEqual(dynamicStatsSpec.map(({ responseKey }) => responseKey)); + }); + + test('the test fixture requests its two backend categories', () => { + expect(getBackendUrlToFetch(defaultLayoutTest)).toEqual([ + '/stats/collection/preparations/', + '/stats/collection/type_specimens/', + ]); + }); + + test('the test fixture has no dynamic queries', () => { + expect(getDynamicQuerySpecsToFetch(defaultLayoutTest)).toEqual([]); + }); +}); + +describe('applying the initial backend responses', () => { + const typeSpecimensUrl = '/stats/collection/type_specimens/'; + const preparationsUrl = '/stats/collection/preparations/'; + const response = { + [typeSpecimensUrl]: { Holotype: 19, Neotype: 1 }, + [preparationsUrl]: { EtOH: { lots: 176, total: 985 } }, + }; + const numberFormatter = (raw: number | undefined): string | undefined => + f.maybe(raw, formatNumber); + const prepFormatter = ( + raw: { readonly lots: number; readonly total: number } | undefined + ): string | undefined => + raw === undefined + ? undefined + : `${formatNumber(raw.lots)} / ${formatNumber(raw.total)}`; + + const loadedLayout: RA = defaultLayoutTest.map((page) => ({ + ...page, + categories: page.categories.map((category) => ({ + ...category, + items: applyStatBackendResponse( + response, + applyStatBackendResponse( + response, + category.items, + typeSpecimensUrl, + numberFormatter, + statsSpecTest + ), + preparationsUrl, + prepFormatter, + statsSpecTest + ), + })), + })); + + test('expands phantom items into one item per response key', () => { + expect( + loadedLayout[0].categories[3].items.map(({ label }) => label) + ).toEqual(['Holotype', 'Neotype']); + expect( + loadedLayout[0].categories[1].items.map(({ itemValue }) => itemValue) + ).toEqual(['176 / 985']); + }); + + test('does not touch categories without a phantom item', () => { + expect(loadedLayout[0].categories[0]).toEqual( + defaultLayoutTest[0].categories[0] + ); + expect(loadedLayout[0].categories[2]).toEqual( + defaultLayoutTest[0].categories[2] + ); + expect(loadedLayout[1]).toEqual(defaultLayoutTest[1]); + }); + + test('loaded categories are not requested again', () => { + expect(getBackendUrlToFetch(loadedLayout)).toEqual([]); + }); + + test('an empty backend response leaves the category empty and does not refetch', () => { + const items = applyStatBackendResponse( + { [typeSpecimensUrl]: {} }, + defaultLayoutTest[0].categories[3].items, + typeSpecimensUrl, + numberFormatter, + statsSpecTest + ); + expect(items).toEqual([]); + expect( + getBackendUrlToFetch([ + { + ...defaultLayoutTest[0], + categories: [{ label: 'Type specimens', items }], + }, + ]) + ).toEqual([]); + }); +}); + +describe('setLayoutUndefined', () => { + const loadedPage: StatLayout = { + label: 'Page', + lastUpdated: '2026-01-01T00:00:00.000Z', + categories: [ + { + label: 'Category', + items: [ + { + ...(defaultLayoutTest[0].categories[0].items[0] as DefaultStat), + itemValue: 5, + }, + { + type: 'CustomStat', + label: 'Custom', + querySpec: { tableName: 'CollectionObject', fields: [] }, + itemValue: '10', + }, + ], + }, + ], + }; + + test('clears every value and lastUpdated', () => { + const cleared = setLayoutUndefined(loadedPage); + expect(cleared.lastUpdated).toBeUndefined(); + allItems([cleared]).forEach((item) => + expect(item.itemValue).toBeUndefined() + ); + }); + + test('keeps labels and item definitions', () => { + const cleared = setLayoutUndefined(loadedPage); + expect(cleared.label).toBe(loadedPage.label); + expect(cleared.categories[0].label).toBe('Category'); + expect(cleared.categories[0].items.map(({ type }) => type)).toEqual([ + 'DefaultStat', + 'CustomStat', + ]); + }); +}); + +describe('applyRefreshLayout', () => { + const now = new Date('2026-06-01T12:00:00.000Z').valueOf(); + const page = (lastUpdated: string | undefined): StatLayout => ({ + label: 'Page', + lastUpdated, + categories: [ + { + label: 'Category', + items: [ + { + ...(defaultLayoutTest[0].categories[0].items[0] as DefaultStat), + itemValue: 5, + }, + ], + }, + ], + }); + + beforeEach(() => jest.spyOn(Date, 'now').mockReturnValue(now)); + afterEach(() => jest.restoreAllMocks()); + + test('returns undefined when there is no layout yet', () => { + expect(applyRefreshLayout(undefined, 24)).toBeUndefined(); + }); + + test('leaves never-updated pages alone', () => { + const layout = [page(undefined)]; + expect(applyRefreshLayout(layout, 24)).toEqual(layout); + }); + + test('leaves pages with an unparsable lastUpdated alone', () => { + const layout = [page('not a date')]; + expect(applyRefreshLayout(layout, 24)).toEqual(layout); + }); + + test('keeps pages newer than the refresh rate', () => { + const layout = [page(new Date(now - 10 * MINUTE).toISOString())]; + expect(applyRefreshLayout(layout, 24)).toEqual(layout); + }); + + test('clears pages at or past the refresh rate', () => { + const layout = [page(new Date(now - 24 * MINUTE).toISOString())]; + expect(applyRefreshLayout(layout, 24)).toEqual([ + setLayoutUndefined(layout[0]), + ]); + }); + + test('leaves pages with a lastUpdated in the future alone', () => { + const layout = [page(new Date(now + MINUTE).toISOString())]; + expect(applyRefreshLayout(layout, 24)).toEqual(layout); + }); + + test('handles each page independently', () => { + const stale = page(new Date(now - 60 * MINUTE).toISOString()); + const fresh = page(new Date(now - MINUTE).toISOString()); + expect(applyRefreshLayout([stale, fresh], 24)).toEqual([ + setLayoutUndefined(stale), + fresh, + ]); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx new file mode 100644 index 00000000000..cb0598fa4f4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -0,0 +1,131 @@ +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { overrideAjax } from '../../../tests/ajax'; +import { mount } from '../../../tests/reactUtils'; +import { f } from '../../../utils/functools'; +import type { IR, RA } from '../../../utils/types'; +import { SetMenuContext } from '../../Header/MenuContext'; +import { BasePreferences } from '../../Preferences/BasePreferences'; +import { collectionPreferenceDefinitions } from '../../Preferences/CollectionDefinitions'; +import { collectionPreferences } from '../../Preferences/collectionPreferences'; +import { userPreferenceDefinitions } from '../../Preferences/UserDefinitions'; +import { userPreferences } from '../../Preferences/userPreferences'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { + getBackendUrlToFetch, + getDynamicQuerySpecsToFetch, + resolveStatsSpec, +} from '../hooks'; +import { StatsPage } from '../index'; +import { defaultLayoutGenerated } from '../StatsSpec'; +import type { CustomStat, DefaultStat, StatLayout } from '../types'; + +// Responses for backend stats on the default shared page. Others return {} +const backendResponses: IR> = { + '/stats/collection/preparations/': { + 'C&S': { lots: 826, total: 430 }, + EtOH: { lots: 176, total: 985 }, + }, + '/stats/collection/locality_geography/percentGeoReferenced/': { + percentGeoReferenced: 42, + }, + '/stats/collection/attachments/percentCoImaged/': { + percentCoImaged: 17, + }, +}; + +export function mockStatsRequests(): void { + const backendUrls = Array.from( + new Set([ + ...getBackendUrlToFetch(defaultLayoutGenerated), + ...defaultLayoutGenerated + .flatMap(({ categories }) => categories.flatMap(({ items }) => items)) + .map((item) => + item.type === 'DefaultStat' && item.itemType === 'BackEndStat' + ? resolveStatsSpec(item, { showPreparationsTotal: true }) + : undefined + ) + .map((spec) => + spec?.type === 'BackEndStat' ? spec.fetchUrl : undefined + ) + .filter((url): url is string => url !== undefined), + ]) + ); + + backendUrls.forEach((url) => overrideAjax(url, backendResponses[url] ?? {})); + + overrideAjax( + '/stored_query/ephemeral/', + { + count: 7, + results: [ + [1, 'Holotype'], + [2, 'Neotype'], + ], + }, + { method: 'POST' } + ); +} + +const makeEmptyPreferences = ( + definitions: DEFINITIONS +) => + new BasePreferences({ + definitions: definitions as never, + values: { resourceName: 'Test', fetchUrl: '/context/test/' }, + defaultValues: undefined, + developmentGlobal: '_testPreferences', + syncChanges: false, + }); + +const getLayout = ( + preferences: ReturnType +): RA | undefined => + preferences.get( + 'statistics' as never, + 'appearance' as never, + 'layout' as never + ); + +const isQueryStat = (item: CustomStat | DefaultStat): boolean => + item.type === 'CustomStat' || item.itemType === 'QueryStat'; + +async function waitForInitialLoad( + getSharedLayout: () => RA | undefined +): Promise> { + await waitFor(() => { + const layout = getSharedLayout(); + expect(layout).toBeDefined(); + expect(layout![0].lastUpdated).toBeDefined(); + expect(getBackendUrlToFetch(layout!)).toEqual([]); + expect(getDynamicQuerySpecsToFetch(layout!)).toEqual([]); + layout![0].categories + .flatMap(({ items }) => items) + .filter(isQueryStat) + .forEach((item) => expect(item.itemValue).toBeDefined()); + }); + return getSharedLayout()!; +} +export async function renderEmptyStatsPage() { + const collection = makeEmptyPreferences(collectionPreferenceDefinitions); + const user = makeEmptyPreferences(userPreferenceDefinitions); + + const result = mount( + + + + + + + + + + ); + const getSharedLayout = () => getLayout(collection); + return { + ...result, + sharedLayout: await waitForInitialLoad(getSharedLayout), + getSharedLayout, + getPersonalLayout: () => getLayout(user), + }; +}