From c45cabcd8ec169c4710efe45e7949666bd8f200e Mon Sep 17 00:00:00 2001 From: kwhuber Date: Tue, 6 Oct 2026 15:10:42 -0500 Subject: [PATCH 1/6] [test]: undergo initial load --- .../Statistics/__tests__/initialLoad.test.ts | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/initialLoad.test.ts 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, + ]); + }); +}); From db015dd5d4dde98a15bfef4e2acb6bdd6d3ab0cf Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 15 Oct 2026 15:02:59 -0500 Subject: [PATCH 2/6] (test): component test for StatsPage with no saved layout --- .../__tests__/StatsPageInitialLoad.test.tsx | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx 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..04a8022c0ac --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx @@ -0,0 +1,230 @@ +import { screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { statsText } from '../../../localization/stats'; +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import { f } from '../../../utils/functools'; +import type { 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 { + getBackendUrlToFetch, + getDynamicQuerySpecsToFetch, + resolveStatsSpec, +} from '../hooks'; +import { StatsPage } from '../index'; +import { defaultLayoutGenerated } from '../StatsSpec'; +import type { CustomStat, DefaultStat, StatLayout } from '../types'; + +requireContext(); + +const preparationsUrl = '/stats/collection/preparations/'; + +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, + url === preparationsUrl + ? { + 'C&S': { lots: 826, total: 430 }, + EtOH: { lots: 176, total: 985 }, + } + : {} + ) +); + +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()!; +} + +async function renderEmptyStatsPage() { + const collection = makeEmptyPreferences(collectionPreferenceDefinitions); + const user = makeEmptyPreferences(userPreferenceDefinitions); + + const result = mount( + + + + + + + + ); + const getSharedLayout = () => getLayout(collection); + const sharedLayout = await waitForInitialLoad(getSharedLayout); + return { + ...result, + sharedLayout, + personalLayout: getLayout(user), + }; +} + +const findCategory = (layout: RA, label: string) => + layout[0].categories.find((category) => category.label === label); + +describe('Statistics initial load with no saved layout', () => { + test('starts with no layout preference', () => { + expect( + getLayout(makeEmptyPreferences(collectionPreferenceDefinitions)) + ).toBeUndefined(); + expect( + getLayout(makeEmptyPreferences(userPreferenceDefinitions)) + ).toBeUndefined(); + }); + + 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 { personalLayout } = await renderEmptyStatsPage(); + expect(personalLayout).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 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() + ); + }); + + test('loads a value for every query stat on the shared page', async () => { + const { sharedLayout } = await renderEmptyStatsPage(); + const queryStats = sharedLayout[0].categories + .flatMap(({ items }) => items) + .filter(isQueryStat); + expect(queryStats.length).toBeGreaterThan(0); + queryStats.forEach((item) => expect(item.itemValue).toBeDefined()); + }); +}); From 789a27e5f4c79876710f7a9abd155e4dcd4544ed Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 16 Oct 2026 09:38:57 -0500 Subject: [PATCH 3/6] (refactor): some deletions to make tests more modular --- .../__tests__/StatsPageInitialLoad.test.tsx | 150 +----------------- 1 file changed, 6 insertions(+), 144 deletions(-) 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 index 04a8022c0ac..ac4193ef136 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx @@ -1,146 +1,17 @@ -import { screen, waitFor } from '@testing-library/react'; -import React from 'react'; +import { screen } from '@testing-library/react'; import { statsText } from '../../../localization/stats'; -import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; -import { mount } from '../../../tests/reactUtils'; -import { f } from '../../../utils/functools'; import type { 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 { - getBackendUrlToFetch, - getDynamicQuerySpecsToFetch, - resolveStatsSpec, -} from '../hooks'; -import { StatsPage } from '../index'; import { defaultLayoutGenerated } from '../StatsSpec'; -import type { CustomStat, DefaultStat, StatLayout } from '../types'; - +import type { StatLayout } from '../types'; +import { mockStatsRequests, renderEmptyStatsPage } from './statsPageTestUtils'; requireContext(); - -const preparationsUrl = '/stats/collection/preparations/'; - -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, - url === preparationsUrl - ? { - 'C&S': { lots: 826, total: 430 }, - EtOH: { lots: 176, total: 985 }, - } - : {} - ) -); - -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()!; -} - -async function renderEmptyStatsPage() { - const collection = makeEmptyPreferences(collectionPreferenceDefinitions); - const user = makeEmptyPreferences(userPreferenceDefinitions); - - const result = mount( - - - - - - - - ); - const getSharedLayout = () => getLayout(collection); - const sharedLayout = await waitForInitialLoad(getSharedLayout); - return { - ...result, - sharedLayout, - personalLayout: getLayout(user), - }; -} +mockStatsRequests(); const findCategory = (layout: RA, label: string) => layout[0].categories.find((category) => category.label === label); describe('Statistics initial load with no saved layout', () => { - test('starts with no layout preference', () => { - expect( - getLayout(makeEmptyPreferences(collectionPreferenceDefinitions)) - ).toBeUndefined(); - expect( - getLayout(makeEmptyPreferences(userPreferenceDefinitions)) - ).toBeUndefined(); - }); - test('saves the default shared page to collection preferences', async () => { const { sharedLayout } = await renderEmptyStatsPage(); expect(sharedLayout).toHaveLength(1); @@ -151,8 +22,8 @@ describe('Statistics initial load with no saved layout', () => { }); test('saves the default personal page to user preferences without loading it', async () => { - const { personalLayout } = await renderEmptyStatsPage(); - expect(personalLayout).toEqual([defaultLayoutGenerated[1]]); + const { getPersonalLayout } = await renderEmptyStatsPage(); + expect(getPersonalLayout()).toEqual([defaultLayoutGenerated[1]]); }); test('marks the active page as refreshed', async () => { @@ -218,13 +89,4 @@ describe('Statistics initial load with no saved layout', () => { expect(item.itemValue).toBeDefined() ); }); - - test('loads a value for every query stat on the shared page', async () => { - const { sharedLayout } = await renderEmptyStatsPage(); - const queryStats = sharedLayout[0].categories - .flatMap(({ items }) => items) - .filter(isQueryStat); - expect(queryStats.length).toBeGreaterThan(0); - queryStats.forEach((item) => expect(item.itemValue).toBeDefined()); - }); }); From 32e895335cf903907d5bd3ad225f2f193cb8a682 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 16 Oct 2026 10:08:05 -0500 Subject: [PATCH 4/6] (refactor): utils file for shared functionality --- .../__tests__/statsPageTestUtils.tsx | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx 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..633fdf191d3 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -0,0 +1,154 @@ +/** + * Shared setup for tests that render the whole StatsPage. + * + * Not a test file itself (doesn't match Jest's testMatch). + */ + +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 { 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'; + +const preparationsUrl = '/stats/collection/preparations/'; + +/** + * Mock every endpoint the default layout hits. Call once at the top level of + * a test file (it registers beforeAll/afterAll hooks). + */ +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, + url === preparationsUrl + ? { + 'C&S': { lots: 826, total: 430 }, + EtOH: { lots: 176, total: 985 }, + } + : {} + ) + ); + + /* + * The same endpoint serves count queries (QueryStat items) and the distinct + * value queries that populate dynamic categories (e.g., type specimens) + */ + overrideAjax( + '/stored_query/ephemeral/', + { + count: 7, + results: [ + [1, 'Holotype'], + [2, 'Neotype'], + ], + }, + { method: 'POST' } + ); +} + +/** Fresh, empty preferences that never sync to the back-end */ +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'; + +/** + * Wait until the default shared page has finished loading, so that no state + * updates happen after the test's assertions + */ +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()!; +} + +/** + * Render StatsPage as if statistics had never been used, and wait for the + * initial load of the default shared page to finish + */ +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), + }; +} From 9ff871518ff1521cec0922371af963c7e89cbe27 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 16 Oct 2026 10:18:24 -0500 Subject: [PATCH 5/6] (test): add new Shared page --- .../__tests__/StatsPageAddSharedPage.test.tsx | 118 ++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 25 ---- 2 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx 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__/statsPageTestUtils.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx index 633fdf191d3..4c4f53737ed 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -1,12 +1,5 @@ -/** - * Shared setup for tests that render the whole StatsPage. - * - * Not a test file itself (doesn't match Jest's testMatch). - */ - 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'; @@ -29,10 +22,6 @@ import type { CustomStat, DefaultStat, StatLayout } from '../types'; const preparationsUrl = '/stats/collection/preparations/'; -/** - * Mock every endpoint the default layout hits. Call once at the top level of - * a test file (it registers beforeAll/afterAll hooks). - */ export function mockStatsRequests(): void { const backendUrls = Array.from( new Set([ @@ -63,10 +52,6 @@ export function mockStatsRequests(): void { ) ); - /* - * The same endpoint serves count queries (QueryStat items) and the distinct - * value queries that populate dynamic categories (e.g., type specimens) - */ overrideAjax( '/stored_query/ephemeral/', { @@ -80,7 +65,6 @@ export function mockStatsRequests(): void { ); } -/** Fresh, empty preferences that never sync to the back-end */ const makeEmptyPreferences = ( definitions: DEFINITIONS ) => @@ -104,10 +88,6 @@ const getLayout = ( const isQueryStat = (item: CustomStat | DefaultStat): boolean => item.type === 'CustomStat' || item.itemType === 'QueryStat'; -/** - * Wait until the default shared page has finished loading, so that no state - * updates happen after the test's assertions - */ async function waitForInitialLoad( getSharedLayout: () => RA | undefined ): Promise> { @@ -124,11 +104,6 @@ async function waitForInitialLoad( }); return getSharedLayout()!; } - -/** - * Render StatsPage as if statistics had never been used, and wait for the - * initial load of the default shared page to finish - */ export async function renderEmptyStatsPage() { const collection = makeEmptyPreferences(collectionPreferenceDefinitions); const user = makeEmptyPreferences(userPreferenceDefinitions); From ecc494104d34efc042be2d4723350dc63934be47 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 16 Oct 2026 10:29:53 -0500 Subject: [PATCH 6/6] (fix): changes from coderabbit suggestion --- .../__tests__/StatsPageInitialLoad.test.tsx | 13 +++++++++ .../__tests__/statsPageTestUtils.tsx | 28 ++++++++++--------- 2 files changed, 28 insertions(+), 13 deletions(-) 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 index ac4193ef136..fcc26f3a6f5 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageInitialLoad.test.tsx @@ -75,6 +75,19 @@ describe('Statistics initial load with no saved layout', () => { 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( diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx index 4c4f53737ed..cb0598fa4f4 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { overrideAjax } from '../../../tests/ajax'; import { mount } from '../../../tests/reactUtils'; import { f } from '../../../utils/functools'; -import type { RA } from '../../../utils/types'; +import type { IR, RA } from '../../../utils/types'; import { SetMenuContext } from '../../Header/MenuContext'; import { BasePreferences } from '../../Preferences/BasePreferences'; import { collectionPreferenceDefinitions } from '../../Preferences/CollectionDefinitions'; @@ -20,7 +20,19 @@ import { StatsPage } from '../index'; import { defaultLayoutGenerated } from '../StatsSpec'; import type { CustomStat, DefaultStat, StatLayout } from '../types'; -const preparationsUrl = '/stats/collection/preparations/'; +// 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( @@ -40,17 +52,7 @@ export function mockStatsRequests(): void { ]) ); - backendUrls.forEach((url) => - overrideAjax( - url, - url === preparationsUrl - ? { - 'C&S': { lots: 826, total: 430 }, - EtOH: { lots: 176, total: 985 }, - } - : {} - ) - ); + backendUrls.forEach((url) => overrideAjax(url, backendResponses[url] ?? {})); overrideAjax( '/stored_query/ephemeral/',