From c45cabcd8ec169c4710efe45e7949666bd8f200e Mon Sep 17 00:00:00 2001 From: kwhuber Date: Tue, 6 Oct 2026 15:10:42 -0500 Subject: [PATCH 01/24] [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 02/24] (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 03/24] (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 04/24] (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 05/24] (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 06/24] (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/', From 1e82e49c21efbe8aee72f1eb49b12545f253f039 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 22 Oct 2026 12:59:08 -0500 Subject: [PATCH 07/24] (test): add new and multiple Shared pages --- .../__tests__/StatsPageAddSharedPage.test.tsx | 58 +++++++++++++++++-- 1 file changed, 52 insertions(+), 6 deletions(-) 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 index e0052a4d606..29be637e95f 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx @@ -29,20 +29,21 @@ const getAddPageButton = () => name: commonText.add(), }); -const getEditButton = (): HTMLElement => +const findEditButton = (): HTMLElement | undefined => screen .getAllByRole('button', { name: commonText.edit() }) - .find(({ textContent }) => textContent === commonText.edit())!; + .find(({ textContent }) => textContent === commonText.edit()); async function openAddPageDialog(user: User): Promise { - await user.click(getEditButton()); + const editButton = findEditButton(); + if (editButton !== undefined) await user.click(editButton); await user.click(getAddPageButton()); return screen.getByRole('dialog', { name: statsText.addPage() }); } -async function addSharedPage(user: User): Promise { +async function addSharedPage(user: User, name = newPageName): Promise { const dialog = await openAddPageDialog(user); - await user.type(within(dialog).getByRole('textbox'), newPageName); + await user.type(within(dialog).getByRole('textbox'), name); await user.click( within(dialog).getByRole('button', { name: commonText.add() }) ); @@ -115,4 +116,49 @@ describe('Adding a new Shared page', () => { expect(getSharedLayout()).toHaveLength(1); expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); }); -}); \ No newline at end of file +}); + +describe('Adding multiple Shared pages', () => { + const newPageNames = ['First Page', 'Second Page', 'Third Page']; + + test('adds each page to the end of Shared and opens the newest one', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + + for (const [index, name] of newPageNames.entries()) { + await addSharedPage(user, name); + const expectedNames = [ + defaultPageName, + ...newPageNames.slice(0, index + 1), + ]; + expect(getPageNames(statsText.shared())).toEqual(expectedNames); + expect(getSharedLayout()?.map(({ label }) => label)).toEqual( + expectedNames + ); + + const pages = getPageButtons(statsText.shared()); + expect(pages.at(-1)).toHaveAttribute('aria-current', 'page'); + pages + .slice(0, -1) + .forEach((page) => expect(page).not.toHaveAttribute('aria-current')); + } + + getSharedLayout()! + .slice(1) + .forEach((page) => expect(page.categories).toEqual([])); + }); + + test('Cancel discards every page added in the edit session', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + for (const name of newPageNames) await addSharedPage(user, name); + 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' + ); + }); +}); From 451afa60ddd3e9f08bc8e1c0a5ca57b676325f78 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 22 Oct 2026 13:29:45 -0500 Subject: [PATCH 08/24] (test): delete Shared pages --- .../__tests__/StatsPageAddSharedPage.test.tsx | 51 ++-------- .../StatsPageDeleteSharedPages.test.tsx | 92 +++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 66 ++++++++++++- 3 files changed, 167 insertions(+), 42 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.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 index 29be637e95f..60e41fb2595 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddSharedPage.test.tsx @@ -3,52 +3,21 @@ import { commonText } from '../../../localization/common'; import { statsText } from '../../../localization/stats'; import { requireContext } from '../../../tests/helpers'; import { defaultLayoutGenerated } from '../StatsSpec'; -import { mockStatsRequests, renderEmptyStatsPage } from './statsPageTestUtils'; +import { + addSharedPage, + getPageButtons, + getPageNames, + mockStatsRequests, + openAddPageDialog, + 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 findEditButton = (): HTMLElement | undefined => - screen - .getAllByRole('button', { name: commonText.edit() }) - .find(({ textContent }) => textContent === commonText.edit()); - -async function openAddPageDialog(user: User): Promise { - const editButton = findEditButton(); - if (editButton !== undefined) await user.click(editButton); - await user.click(getAddPageButton()); - return screen.getByRole('dialog', { name: statsText.addPage() }); -} - -async function addSharedPage(user: User, name = newPageName): Promise { - const dialog = await openAddPageDialog(user); - await user.type(within(dialog).getByRole('textbox'), name); - 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(); @@ -60,7 +29,7 @@ describe('Adding a new Shared page', () => { const { user, getSharedLayout, getPersonalLayout } = await renderEmptyStatsPage(); const personalLayout = getPersonalLayout(); - await addSharedPage(user); + await addSharedPage(user, newPageName); expect( screen.queryByRole('dialog', { name: statsText.addPage() }) @@ -92,7 +61,7 @@ describe('Adding a new Shared page', () => { test('Cancel discards the new page and reopens the last existing page', async () => { const { user, getSharedLayout } = await renderEmptyStatsPage(); - await addSharedPage(user); + await addSharedPage(user, newPageName); await user.click(screen.getByRole('button', { name: commonText.cancel() })); expect(getSharedLayout()?.map(({ label }) => label)).toEqual([ diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx new file mode 100644 index 00000000000..a4e4aabf50d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx @@ -0,0 +1,92 @@ +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 { + addSharedPage, + getPageButtons, + getPageNames, + mockStatsRequests, + openEditPageDialog, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const defaultPageName = defaultLayoutGenerated[0].label; + +async function deleteSharedPage(user: User, pageName: string): Promise { + const dialog = await openEditPageDialog(user, pageName); + await user.click( + within(dialog).getByRole('button', { name: commonText.remove() }) + ); +} + +const getCurrentPageName = () => + getPageButtons(statsText.shared()).find( + (page) => page.getAttribute('aria-current') === 'page' + )?.textContent; + +describe('Deleting Shared pages', () => { + test('the only Shared page cannot be deleted', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openEditPageDialog(user, defaultPageName); + expect( + within(dialog).queryByRole('button', { name: commonText.remove() }) + ).not.toBeInTheDocument(); + }); + + test('deleting several pages keeps the right page selected', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + for (const name of ['A', 'B', 'C', 'D']) await addSharedPage(user, name); + expect(getCurrentPageName()).toBe('D'); + + const expectPages = (names: readonly string[], current: string) => { + expect( + screen.queryByRole('dialog', { name: statsText.editPage() }) + ).not.toBeInTheDocument(); + expect(getPageNames(statsText.shared())).toEqual(names); + expect(getSharedLayout()?.map(({ label }) => label)).toEqual(names); + expect(getCurrentPageName()).toBe(current); + }; + + // A page before the selected one: selection stays on the same page + await deleteSharedPage(user, 'B'); + expectPages([defaultPageName, 'A', 'C', 'D'], 'D'); + + // The selected page: selection moves to the previous page + await deleteSharedPage(user, 'D'); + expectPages([defaultPageName, 'A', 'C'], 'C'); + + // The first page + await deleteSharedPage(user, defaultPageName); + expectPages(['A', 'C'], 'C'); + + // A page after the selected one: selection doesn't move + await user.click(getPageButtons(statsText.shared())[0]); + await deleteSharedPage(user, 'C'); + expectPages(['A'], 'A'); + + // One page remaining so it can no longer be deleted + const dialog = await openEditPageDialog(user, 'A'); + expect( + within(dialog).queryByRole('button', { name: commonText.remove() }) + ).not.toBeInTheDocument(); + }); + + test('deleting Shared pages does not touch the Private section', async () => { + const { user, getPersonalLayout } = await renderEmptyStatsPage(); + const personalLayout = getPersonalLayout(); + for (const name of ['A', 'B']) await addSharedPage(user, name); + await deleteSharedPage(user, 'A'); + await deleteSharedPage(user, 'B'); + + expect(getPersonalLayout()).toEqual(personalLayout); + expect(getPageNames(statsText.private())).toEqual([ + defaultLayoutGenerated[1].label, + ]); + }); +}); 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 cb0598fa4f4..9384c15eac4 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -1,5 +1,7 @@ -import { waitFor } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import React from 'react'; +import { commonText } from '../../../localization/common'; +import { statsText } from '../../../localization/stats'; import { overrideAjax } from '../../../tests/ajax'; import { mount } from '../../../tests/reactUtils'; import { f } from '../../../utils/functools'; @@ -129,3 +131,65 @@ export async function renderEmptyStatsPage() { getPersonalLayout: () => getLayout(user), }; } + +export type User = Awaited>['user']; + +// The
  • holding a section's heading, "+" button and page list +const getSection = (sectionName: string): HTMLElement => + screen.getByRole('heading', { name: sectionName }).closest('li')!; + +// Each page's
  • holds its page button, then its pencil (rename) button +const getPageItems = (sectionName: string): RA => + within(getSection(sectionName)).getAllByRole('listitem'); + +export const getPageButtons = (sectionName: string): RA => + getPageItems(sectionName).map( + (item) => within(item).getAllByRole('button')[0] + ); + +export const getPageNames = (sectionName: string) => + getPageButtons(sectionName).map(({ textContent }) => textContent); + +// Undefined once in edit mode, where the header shows Cancel/Save instead +const findEditButton = (): HTMLElement | undefined => + screen + .getAllByRole('button', { name: commonText.edit() }) + .find(({ textContent }) => textContent === commonText.edit()); + +async function enterEditMode(user: User): Promise { + const editButton = findEditButton(); + if (editButton !== undefined) await user.click(editButton); +} + +export async function openAddPageDialog(user: User): Promise { + await enterEditMode(user); + await user.click( + within(getSection(statsText.shared())).getByRole('button', { + name: commonText.add(), + }) + ); + return screen.getByRole('dialog', { name: statsText.addPage() }); +} + +export async function addSharedPage(user: User, name: string): Promise { + const dialog = await openAddPageDialog(user); + await user.type(within(dialog).getByRole('textbox'), name); + await user.click( + within(dialog).getByRole('button', { name: commonText.add() }) + ); +} + +/** Open the "Edit Page" dialog for a Shared page using its pencil button */ +export async function openEditPageDialog( + user: User, + pageName: string +): Promise { + await enterEditMode(user); + const pageItem = getPageItems(statsText.shared()).find( + (item) => within(item).getAllByRole('button')[0].textContent === pageName + )!; + await user.click( + within(pageItem).getByRole('button', { name: commonText.edit() }) + ); + return screen.getByRole('dialog', { name: statsText.editPage() }); +} From d1daedb21c09f378572c9ee021c27c092a557cc8 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 22 Oct 2026 13:58:48 -0500 Subject: [PATCH 09/24] (test): rename page with pencil icon --- .../StatsPageDeleteSharedPages.test.tsx | 6 +- .../StatsPageRenameSharedPage.test.tsx | 89 +++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 10 +-- 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameSharedPage.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx index a4e4aabf50d..7fabb82a974 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx @@ -5,6 +5,7 @@ import { requireContext } from '../../../tests/helpers'; import { defaultLayoutGenerated } from '../StatsSpec'; import { addSharedPage, + getCurrentPageName, getPageButtons, getPageNames, mockStatsRequests, @@ -25,11 +26,6 @@ async function deleteSharedPage(user: User, pageName: string): Promise { ); } -const getCurrentPageName = () => - getPageButtons(statsText.shared()).find( - (page) => page.getAttribute('aria-current') === 'page' - )?.textContent; - describe('Deleting Shared pages', () => { test('the only Shared page cannot be deleted', async () => { const { user } = await renderEmptyStatsPage(); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameSharedPage.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameSharedPage.test.tsx new file mode 100644 index 00000000000..d0197cd74f0 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameSharedPage.test.tsx @@ -0,0 +1,89 @@ +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 { + addSharedPage, + getCurrentPageName, + getPageNames, + mockStatsRequests, + openEditPageDialog, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const defaultPageName = defaultLayoutGenerated[0].label; +const newName = 'Renamed Page'; + +async function renameSharedPage( + user: User, + pageName: string, + name: string +): Promise { + const dialog = await openEditPageDialog(user, pageName); + const input = within(dialog).getByRole('textbox'); + await user.clear(input); + await user.type(input, name); + await user.click( + within(dialog).getByRole('button', { name: commonText.save() }) + ); +} + +describe('Renaming a Shared page with the pencil icon', () => { + test('the pencil opens "Edit Page" with the current name', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openEditPageDialog(user, defaultPageName); + expect(within(dialog).getByRole('textbox')).toHaveValue(defaultPageName); + }); + + test('saving renames the page and keeps its content', async () => { + const { user, sharedLayout, getSharedLayout, getPersonalLayout } = + await renderEmptyStatsPage(); + const personalLayout = getPersonalLayout(); + await renameSharedPage(user, defaultPageName, newName); + + expect( + screen.queryByRole('dialog', { name: statsText.editPage() }) + ).not.toBeInTheDocument(); + expect(getPageNames(statsText.shared())).toEqual([newName]); + expect(getCurrentPageName()).toBe(newName); + expect(getSharedLayout()).toEqual([{ ...sharedLayout[0], label: newName }]); + expect(getPersonalLayout()).toEqual(personalLayout); + }); + + test('renaming another page keeps the order and selected page', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + for (const name of ['A', 'B']) await addSharedPage(user, name); + await renameSharedPage(user, 'A', newName); + + expect(getPageNames(statsText.shared())).toEqual([ + defaultPageName, + newName, + 'B', + ]); + expect(getSharedLayout()?.map(({ label }) => label)).toEqual([ + defaultPageName, + newName, + 'B', + ]); + expect(getCurrentPageName()).toBe('B'); + }); + + test('closing the dialog keeps the old name', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openEditPageDialog(user, defaultPageName); + const input = within(dialog).getByRole('textbox'); + await user.clear(input); + await user.type(input, newName); + await user.click( + within(dialog).getByRole('button', { name: commonText.close() }) + ); + + expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); + expect(getSharedLayout()?.[0].label).toBe(defaultPageName); + }); +}); 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 9384c15eac4..f348669fbbb 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -22,7 +22,6 @@ 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 }, @@ -134,11 +133,9 @@ export async function renderEmptyStatsPage() { export type User = Awaited>['user']; -// The
  • holding a section's heading, "+" button and page list const getSection = (sectionName: string): HTMLElement => screen.getByRole('heading', { name: sectionName }).closest('li')!; -// Each page's
  • holds its page button, then its pencil (rename) button const getPageItems = (sectionName: string): RA => within(getSection(sectionName)).getAllByRole('listitem'); @@ -150,7 +147,11 @@ export const getPageButtons = (sectionName: string): RA => export const getPageNames = (sectionName: string) => getPageButtons(sectionName).map(({ textContent }) => textContent); -// Undefined once in edit mode, where the header shows Cancel/Save instead +export const getCurrentPageName = () => + getPageButtons(statsText.shared()).find( + (page) => page.getAttribute('aria-current') === 'page' + )?.textContent; + const findEditButton = (): HTMLElement | undefined => screen .getAllByRole('button', { name: commonText.edit() }) @@ -179,7 +180,6 @@ export async function addSharedPage(user: User, name: string): Promise { ); } -/** Open the "Edit Page" dialog for a Shared page using its pencil button */ export async function openEditPageDialog( user: User, pageName: string From 4fa25799080e07aed294327eaadf1e5ecca7d9af Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 22 Oct 2026 14:22:42 -0500 Subject: [PATCH 10/24] (test): can't save a page without a name --- .../StatsPageRequirePageName.test.tsx | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRequirePageName.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRequirePageName.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRequirePageName.test.tsx new file mode 100644 index 00000000000..ff83275fcd4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRequirePageName.test.tsx @@ -0,0 +1,48 @@ +import { 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 { + getPageNames, + mockStatsRequests, + openAddPageDialog, + openEditPageDialog, + renderEmptyStatsPage, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const defaultPageName = defaultLayoutGenerated[0].label; + +describe("Can't save a Shared page without a name", () => { + test('adding a page with no name is blocked', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openAddPageDialog(user); + const input = within(dialog).getByRole('textbox'); + await user.click( + within(dialog).getByRole('button', { name: commonText.add() }) + ); + + expect(input).toBeInvalid(); + expect(dialog).toBeInTheDocument(); + expect(getSharedLayout()).toHaveLength(1); + expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); + }); + + test('renaming a page to no name is blocked', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openEditPageDialog(user, defaultPageName); + const input = within(dialog).getByRole('textbox'); + await user.clear(input); + await user.click( + within(dialog).getByRole('button', { name: commonText.save() }) + ); + + expect(input).toBeInvalid(); + expect(dialog).toBeInTheDocument(); + expect(getSharedLayout()?.[0].label).toBe(defaultPageName); + expect(getPageNames(statsText.shared())).toEqual([defaultPageName]); + }); +}); From ea34beb03d8cd335420c63248f890485957df007 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 22 Oct 2026 15:08:04 -0500 Subject: [PATCH 11/24] (test): verify adding long names looks okay --- .../__tests__/StatsPageLongPageName.test.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageLongPageName.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageLongPageName.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageLongPageName.test.tsx new file mode 100644 index 00000000000..ae5e06626e8 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageLongPageName.test.tsx @@ -0,0 +1,51 @@ +/** + * jsdom doesn't understand layout, so this can't check how a long name + * actually looks. It checks that the full name is kept and that the page + * button keeps the classes that cap its width and scroll overflowing text. + */ + +import { within } from '@testing-library/react'; +import { statsText } from '../../../localization/stats'; +import { requireContext } from '../../../tests/helpers'; +import { + addSharedPage, + getPageButtons, + getPageNames, + mockStatsRequests, + openEditPageDialog, + renderEmptyStatsPage, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const longNames = { + 'with spaces': 'A very long statistics page name '.repeat(5).trim(), + 'without spaces': 'X'.repeat(200), +}; + +describe('Adding a Shared page with a long name', () => { + test.each(Object.entries(longNames))( + 'keeps the full name (%s)', + async (_, name) => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addSharedPage(user, name); + + expect(getPageNames(statsText.shared()).at(-1)).toBe(name); + expect(getSharedLayout()?.at(-1)?.label).toBe(name); + + const dialog = await openEditPageDialog(user, name); + expect(within(dialog).getByRole('textbox')).toHaveValue(name); + } + ); + + test('the page button caps its width and scrolls overflowing text', async () => { + const { user } = await renderEmptyStatsPage(); + await addSharedPage(user, longNames['without spaces']); + + expect(getPageButtons(statsText.shared()).at(-1)).toHaveClass( + 'max-w-xs', + 'overflow-x-auto' + ); + }); +}); From c07908ebc69a5759db92df4934d6a6a9063b5363 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 08:42:31 -0500 Subject: [PATCH 12/24] (test): add new private page --- .../StatsPageAddPrivatePage.test.tsx | 82 +++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 24 ++++-- 2 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx new file mode 100644 index 00000000000..18e2b703e7a --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx @@ -0,0 +1,82 @@ +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 { + addPage, + getCurrentPageName, + getPageNames, + mockStatsRequests, + openAddPageDialog, + renderEmptyStatsPage, + waitForInitialLoad, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const privateSection = statsText.private(); +const defaultPrivatePageName = defaultLayoutGenerated[1].label; +const newPageName = 'My Private Page'; + +describe('Adding a new Private page', () => { + test('"+" opens an empty "Add Page" dialog', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openAddPageDialog(user, privateSection); + expect(within(dialog).getByRole('textbox')).toHaveValue(''); + }); + + test('adds an empty page to the end of Private and opens it', async () => { + const { user, getSharedLayout, getPersonalLayout } = + await renderEmptyStatsPage(); + const sharedLayout = getSharedLayout(); + await addPage(user, newPageName, privateSection); + + expect( + screen.queryByRole('dialog', { name: statsText.addPage() }) + ).not.toBeInTheDocument(); + expect(getPageNames(privateSection)).toEqual([ + defaultPrivatePageName, + newPageName, + ]); + expect(getCurrentPageName(privateSection)).toBe(newPageName); + expect(getCurrentPageName()).toBeUndefined(); + + expect(getPersonalLayout()?.[1]).toMatchObject({ + label: newPageName, + categories: [], + }); + expect( + screen.getByRole('button', { name: statsText.addACategory() }) + ).toBeInTheDocument(); + + expect(getSharedLayout()).toEqual(sharedLayout); + }); + + test('Cancel discards the new page and reopens the last existing page', async () => { + const { user, getPersonalLayout } = await renderEmptyStatsPage(); + await addPage(user, newPageName, privateSection); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + // The default Private page was never opened, so it starts loading now + const personalLayout = await waitForInitialLoad(getPersonalLayout); + expect(personalLayout.map(({ label }) => label)).toEqual([ + defaultPrivatePageName, + ]); + expect(getPageNames(privateSection)).toEqual([defaultPrivatePageName]); + expect(getCurrentPageName(privateSection)).toBe(defaultPrivatePageName); + }); + + test('closing the dialog does not add a page', async () => { + const { user, getPersonalLayout } = await renderEmptyStatsPage(); + const dialog = await openAddPageDialog(user, privateSection); + await user.type(within(dialog).getByRole('textbox'), newPageName); + await user.click( + within(dialog).getByRole('button', { name: commonText.close() }) + ); + + expect(getPersonalLayout()).toHaveLength(1); + expect(getPageNames(privateSection)).toEqual([defaultPrivatePageName]); + }); +}); 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 f348669fbbb..be365399e25 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -91,7 +91,7 @@ const getLayout = ( const isQueryStat = (item: CustomStat | DefaultStat): boolean => item.type === 'CustomStat' || item.itemType === 'QueryStat'; -async function waitForInitialLoad( +export async function waitForInitialLoad( getSharedLayout: () => RA | undefined ): Promise> { await waitFor(() => { @@ -147,8 +147,8 @@ export const getPageButtons = (sectionName: string): RA => export const getPageNames = (sectionName: string) => getPageButtons(sectionName).map(({ textContent }) => textContent); -export const getCurrentPageName = () => - getPageButtons(statsText.shared()).find( +export const getCurrentPageName = (sectionName: string = statsText.shared()) => + getPageButtons(sectionName).find( (page) => page.getAttribute('aria-current') === 'page' )?.textContent; @@ -162,24 +162,34 @@ async function enterEditMode(user: User): Promise { if (editButton !== undefined) await user.click(editButton); } -export async function openAddPageDialog(user: User): Promise { +export async function openAddPageDialog( + user: User, + sectionName: string = statsText.shared() +): Promise { await enterEditMode(user); await user.click( - within(getSection(statsText.shared())).getByRole('button', { + within(getSection(sectionName)).getByRole('button', { name: commonText.add(), }) ); return screen.getByRole('dialog', { name: statsText.addPage() }); } -export async function addSharedPage(user: User, name: string): Promise { - const dialog = await openAddPageDialog(user); +export async function addPage( + user: User, + name: string, + sectionName: string +): Promise { + const dialog = await openAddPageDialog(user, sectionName); await user.type(within(dialog).getByRole('textbox'), name); await user.click( within(dialog).getByRole('button', { name: commonText.add() }) ); } +export const addSharedPage = async (user: User, name: string): Promise => + addPage(user, name, statsText.shared()); + export async function openEditPageDialog( user: User, pageName: string From c1995525e6f4031f7b303ca290e9e9bfb0accf63 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 09:05:16 -0500 Subject: [PATCH 13/24] (test): add multiple Private pages --- .../StatsPageAddPrivatePage.test.tsx | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx index 18e2b703e7a..b74733cee3d 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddPrivatePage.test.tsx @@ -59,7 +59,6 @@ describe('Adding a new Private page', () => { await addPage(user, newPageName, privateSection); await user.click(screen.getByRole('button', { name: commonText.cancel() })); - // The default Private page was never opened, so it starts loading now const personalLayout = await waitForInitialLoad(getPersonalLayout); expect(personalLayout.map(({ label }) => label)).toEqual([ defaultPrivatePageName, @@ -80,3 +79,44 @@ describe('Adding a new Private page', () => { expect(getPageNames(privateSection)).toEqual([defaultPrivatePageName]); }); }); + +describe('Adding multiple Private pages', () => { + const newPageNames = ['First Page', 'Second Page', 'Third Page']; + + test('adds each page to the end of Private and opens the newest one', async () => { + const { user, getSharedLayout, getPersonalLayout } = + await renderEmptyStatsPage(); + const sharedLayout = getSharedLayout(); + + for (const [index, name] of newPageNames.entries()) { + await addPage(user, name, privateSection); + const expectedNames = [ + defaultPrivatePageName, + ...newPageNames.slice(0, index + 1), + ]; + expect(getPageNames(privateSection)).toEqual(expectedNames); + expect(getPersonalLayout()?.map(({ label }) => label)).toEqual( + expectedNames + ); + expect(getCurrentPageName(privateSection)).toBe(name); + } + + getPersonalLayout()! + .slice(1) + .forEach((page) => expect(page.categories).toEqual([])); + expect(getSharedLayout()).toEqual(sharedLayout); + }); + + test('Cancel discards every page added in the edit session', async () => { + const { user, getPersonalLayout } = await renderEmptyStatsPage(); + for (const name of newPageNames) await addPage(user, name, privateSection); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + const personalLayout = await waitForInitialLoad(getPersonalLayout); + expect(personalLayout.map(({ label }) => label)).toEqual([ + defaultPrivatePageName, + ]); + expect(getPageNames(privateSection)).toEqual([defaultPrivatePageName]); + expect(getCurrentPageName(privateSection)).toBe(defaultPrivatePageName); + }); +}); From 77e4f202bdef260ec7791364c6f3a454d8a885ef Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 09:34:40 -0500 Subject: [PATCH 14/24] (test): delete Private pages --- .../StatsPageDeletePrivatePages.test.tsx | 90 +++++++++++++++++++ .../StatsPageDeleteSharedPages.test.tsx | 21 ++--- .../__tests__/statsPageTestUtils.tsx | 16 +++- 3 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeletePrivatePages.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeletePrivatePages.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeletePrivatePages.test.tsx new file mode 100644 index 00000000000..647adc57d0d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeletePrivatePages.test.tsx @@ -0,0 +1,90 @@ +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 { + addPage, + deletePage, + getCurrentPageName, + getPageButtons, + getPageNames, + mockStatsRequests, + openEditPageDialog, + renderEmptyStatsPage, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const privateSection = statsText.private(); +const defaultPrivatePageName = defaultLayoutGenerated[1].label; +const defaultSharedPageName = defaultLayoutGenerated[0].label; + +describe('Deleting Private pages', () => { + test('the only Private page cannot be deleted', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openEditPageDialog( + user, + defaultPrivatePageName, + privateSection + ); + expect( + within(dialog).queryByRole('button', { name: commonText.remove() }) + ).not.toBeInTheDocument(); + }); + + test('deleting several pages keeps the right page selected', async () => { + const { user, getPersonalLayout } = await renderEmptyStatsPage(); + for (const name of ['A', 'B', 'C', 'D']) + await addPage(user, name, privateSection); + expect(getCurrentPageName(privateSection)).toBe('D'); + + const expectPages = (names: readonly string[], current: string) => { + expect( + screen.queryByRole('dialog', { name: statsText.editPage() }) + ).not.toBeInTheDocument(); + expect(getPageNames(privateSection)).toEqual(names); + expect(getPersonalLayout()?.map(({ label }) => label)).toEqual(names); + expect(getCurrentPageName(privateSection)).toBe(current); + }; + + // A page before the selected one: selection stays on the same page + await deletePage(user, 'B', privateSection); + expectPages([defaultPrivatePageName, 'A', 'C', 'D'], 'D'); + + // The selected page: selection moves to the previous page + await deletePage(user, 'D', privateSection); + expectPages([defaultPrivatePageName, 'A', 'C'], 'C'); + + // The first page + await deletePage(user, defaultPrivatePageName, privateSection); + expectPages(['A', 'C'], 'C'); + + // A page after the selected one: selection doesn't move + await user.click(getPageButtons(privateSection)[0]); + await deletePage(user, 'C', privateSection); + expectPages(['A'], 'A'); + + // One page remaining so it can no longer be deleted + const dialog = await openEditPageDialog(user, 'A', privateSection); + expect( + within(dialog).queryByRole('button', { name: commonText.remove() }) + ).not.toBeInTheDocument(); + }); + + test('deleting Private pages while a Shared page is open keeps it open', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + for (const name of ['A', 'B']) await addPage(user, name, privateSection); + const sharedLayout = getSharedLayout(); + await user.click(getPageButtons(statsText.shared())[0]); + + await deletePage(user, 'A', privateSection); + await deletePage(user, 'B', privateSection); + + expect(getPageNames(privateSection)).toEqual([defaultPrivatePageName]); + expect(getCurrentPageName()).toBe(defaultSharedPageName); + expect(getCurrentPageName(privateSection)).toBeUndefined(); + expect(getSharedLayout()).toEqual(sharedLayout); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx index 7fabb82a974..e410602d780 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteSharedPages.test.tsx @@ -5,13 +5,13 @@ import { requireContext } from '../../../tests/helpers'; import { defaultLayoutGenerated } from '../StatsSpec'; import { addSharedPage, + deletePage, getCurrentPageName, getPageButtons, getPageNames, mockStatsRequests, openEditPageDialog, renderEmptyStatsPage, - type User, } from './statsPageTestUtils'; requireContext(); @@ -19,13 +19,6 @@ mockStatsRequests(); const defaultPageName = defaultLayoutGenerated[0].label; -async function deleteSharedPage(user: User, pageName: string): Promise { - const dialog = await openEditPageDialog(user, pageName); - await user.click( - within(dialog).getByRole('button', { name: commonText.remove() }) - ); -} - describe('Deleting Shared pages', () => { test('the only Shared page cannot be deleted', async () => { const { user } = await renderEmptyStatsPage(); @@ -50,20 +43,20 @@ describe('Deleting Shared pages', () => { }; // A page before the selected one: selection stays on the same page - await deleteSharedPage(user, 'B'); + await deletePage(user, 'B'); expectPages([defaultPageName, 'A', 'C', 'D'], 'D'); // The selected page: selection moves to the previous page - await deleteSharedPage(user, 'D'); + await deletePage(user, 'D'); expectPages([defaultPageName, 'A', 'C'], 'C'); // The first page - await deleteSharedPage(user, defaultPageName); + await deletePage(user, defaultPageName); expectPages(['A', 'C'], 'C'); // A page after the selected one: selection doesn't move await user.click(getPageButtons(statsText.shared())[0]); - await deleteSharedPage(user, 'C'); + await deletePage(user, 'C'); expectPages(['A'], 'A'); // One page remaining so it can no longer be deleted @@ -77,8 +70,8 @@ describe('Deleting Shared pages', () => { const { user, getPersonalLayout } = await renderEmptyStatsPage(); const personalLayout = getPersonalLayout(); for (const name of ['A', 'B']) await addSharedPage(user, name); - await deleteSharedPage(user, 'A'); - await deleteSharedPage(user, 'B'); + await deletePage(user, 'A'); + await deletePage(user, 'B'); expect(getPersonalLayout()).toEqual(personalLayout); expect(getPageNames(statsText.private())).toEqual([ 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 be365399e25..578c57ddd8f 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -192,10 +192,11 @@ export const addSharedPage = async (user: User, name: string): Promise => export async function openEditPageDialog( user: User, - pageName: string + pageName: string, + sectionName: string = statsText.shared() ): Promise { await enterEditMode(user); - const pageItem = getPageItems(statsText.shared()).find( + const pageItem = getPageItems(sectionName).find( (item) => within(item).getAllByRole('button')[0].textContent === pageName )!; await user.click( @@ -203,3 +204,14 @@ export async function openEditPageDialog( ); return screen.getByRole('dialog', { name: statsText.editPage() }); } + +export async function deletePage( + user: User, + pageName: string, + sectionName: string = statsText.shared() +): Promise { + const dialog = await openEditPageDialog(user, pageName, sectionName); + await user.click( + within(dialog).getByRole('button', { name: commonText.remove() }) + ); +} From 304681cbd0b0ddfd0de7c48df3f8c05ef4b9468e Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 10:05:36 -0500 Subject: [PATCH 15/24] (test): edit an existing category --- .../__tests__/StatsPageEditCategory.test.tsx | 62 +++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 2 +- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx new file mode 100644 index 00000000000..c12360022d3 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx @@ -0,0 +1,62 @@ +import { screen } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { + enterEditMode, + mockStatsRequests, + renderEmptyStatsPage, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const newName = 'Renamed Category'; + +const getCategoryInputs = (): RA => + screen + .getAllByRole('textbox') + .filter((input) => input.closest('ul') === null); + +describe('Editing an existing category', () => { + test('edit mode shows each category name in an editable field', async () => { + const { user, sharedLayout } = await renderEmptyStatsPage(); + await enterEditMode(user); + + expect(getCategoryInputs().map(({ value }) => value)).toEqual( + sharedLayout[0].categories.map(({ label }) => label) + ); + }); + + test('typing a new name renames only that category', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + const input = getCategoryInputs()[0]; + await user.clear(input); + await user.type(input, newName); + + expect(input).toHaveValue(newName); + const [renamed, ...others] = getSharedLayout()![0].categories; + const [original, ...originalOthers] = sharedLayout[0].categories; + expect(renamed).toEqual({ ...original, label: newName }); + expect(others).toEqual(originalOthers); + }); + + test('Cancel restores the original name', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + const input = getCategoryInputs()[0]; + await user.clear(input); + await user.type(input, newName); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + expect(getSharedLayout()![0].categories).toEqual( + sharedLayout[0].categories + ); + expect( + screen.getByRole('heading', { name: sharedLayout[0].categories[0].label }) + ).toBeInTheDocument(); + }); +}); 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 578c57ddd8f..f8f8e98bcbb 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -157,7 +157,7 @@ const findEditButton = (): HTMLElement | undefined => .getAllByRole('button', { name: commonText.edit() }) .find(({ textContent }) => textContent === commonText.edit()); -async function enterEditMode(user: User): Promise { +export async function enterEditMode(user: User): Promise { const editButton = findEditButton(); if (editButton !== undefined) await user.click(editButton); } From 49e35392eaac935abb71e2058b172c968bdcf489 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 10:13:24 -0500 Subject: [PATCH 16/24] (test): create a new category --- .../StatsPageCreateCategory.test.tsx | 77 +++++++++++++++++++ .../__tests__/StatsPageEditCategory.test.tsx | 7 +- .../__tests__/statsPageTestUtils.tsx | 5 ++ 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateCategory.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateCategory.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateCategory.test.tsx new file mode 100644 index 00000000000..0043847b781 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateCategory.test.tsx @@ -0,0 +1,77 @@ +import { screen } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { statsText } from '../../../localization/stats'; +import { requireContext } from '../../../tests/helpers'; +import { + enterEditMode, + getCategoryInputs, + mockStatsRequests, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const newName = 'New Category'; + +async function addCategory(user: User): Promise { + await enterEditMode(user); + await user.click( + screen.getByRole('button', { name: statsText.addACategory() }) + ); +} + +describe('Creating a new category', () => { + test('"Add a category" adds an empty, unnamed category at the end', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await addCategory(user); + + const inputs = getCategoryInputs(); + expect(inputs).toHaveLength(sharedLayout[0].categories.length + 1); + expect(inputs.at(-1)).toHaveValue(''); + expect(getSharedLayout()![0].categories).toEqual([ + ...sharedLayout[0].categories, + { label: '', items: [] }, + ]); + }); + + test('typing a name names the new category', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await addCategory(user); + await user.type(getCategoryInputs().at(-1)!, newName); + + expect(getSharedLayout()![0].categories).toEqual([ + ...sharedLayout[0].categories, + { label: newName, items: [] }, + ]); + }); + + test('clicking "Add a category" again adds another category', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await addCategory(user); + await user.type(getCategoryInputs().at(-1)!, newName); + await addCategory(user); + + expect(getSharedLayout()![0].categories).toEqual([ + ...sharedLayout[0].categories, + { label: newName, items: [] }, + { label: '', items: [] }, + ]); + }); + + test('Cancel removes the new category', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await addCategory(user); + await user.type(getCategoryInputs().at(-1)!, newName); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + expect(getSharedLayout()![0].categories).toEqual( + sharedLayout[0].categories + ); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx index c12360022d3..56d9776ef9b 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageEditCategory.test.tsx @@ -1,9 +1,9 @@ import { screen } from '@testing-library/react'; import { commonText } from '../../../localization/common'; import { requireContext } from '../../../tests/helpers'; -import type { RA } from '../../../utils/types'; import { enterEditMode, + getCategoryInputs, mockStatsRequests, renderEmptyStatsPage, } from './statsPageTestUtils'; @@ -13,11 +13,6 @@ mockStatsRequests(); const newName = 'Renamed Category'; -const getCategoryInputs = (): RA => - screen - .getAllByRole('textbox') - .filter((input) => input.closest('ul') === null); - describe('Editing an existing category', () => { test('edit mode shows each category name in an editable field', async () => { const { user, sharedLayout } = await renderEmptyStatsPage(); 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 f8f8e98bcbb..4ee341c2d1a 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -215,3 +215,8 @@ export async function deletePage( within(dialog).getByRole('button', { name: commonText.remove() }) ); } + +export const getCategoryInputs = (): RA => + screen + .getAllByRole('textbox') + .filter((input) => input.closest('ul') === null); From 463edd7ca562ec9e27a4cd9c2aa104a5dba0c4d6 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 11:01:51 -0500 Subject: [PATCH 17/24] (test): add several statistics --- .../__tests__/StatsPageAddStatistics.test.tsx | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx new file mode 100644 index 00000000000..1e75df3ddc2 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx @@ -0,0 +1,114 @@ +import { screen, within } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { statsText } from '../../../localization/stats'; +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { defaultLayoutGenerated } from '../StatsSpec'; +import type { RA } from '../../../utils/types'; +import type { DefaultStat, StatLayout } from '../types'; +import { + addSharedPage, + getCategoryInputs, + mockStatsRequests, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); +overrideAjax( + '/api/specify/spquery/?limit=10&specifyuser=2&offset=0&orderby=-name', + { objects: [], meta: { limit: 10, offset: 0, total_count: 0 } } +); + +const newCategoryName = 'My Stats'; + +const findDefaultStat = (category: string, item: string): DefaultStat => + defaultLayoutGenerated[0].categories + .flatMap(({ items }) => items) + .find( + (stat): stat is DefaultStat => + stat.type === 'DefaultStat' && + stat.categoryName === category && + stat.itemName === item + )!; + +const statsToAdd = [ + findDefaultStat('holdings', 'specimens'), + findDefaultStat('loans', 'openLoansCount'), + findDefaultStat('locality_geography', 'localityCount'), +]; + +async function addPageWithCategory(user: User): Promise { + await addSharedPage(user, 'Stats Page'); + await user.click( + screen.getByRole('button', { name: statsText.addACategory() }) + ); + await user.type(getCategoryInputs()[0], newCategoryName); +} + +async function openChooseStatisticsDialog(user: User): Promise { + await user.click( + within(getCategoryInputs()[0].closest('li')!).getByRole('button', { + name: commonText.add(), + }) + ); + return screen.findByRole('dialog', { name: statsText.chooseStatistics() }); +} + +const queryStatButton = (dialog: HTMLElement, label: string) => + within(dialog) + .queryAllByRole('button') + .find((button) => button.firstElementChild?.textContent === label); + +async function addStat(user: User, stat: DefaultStat): Promise { + const dialog = await openChooseStatisticsDialog(user); + await user.click(queryStatButton(dialog, stat.label)!); +} + +const getCategoryItems = (getSharedLayout: () => RA | undefined) => + getSharedLayout()!.at(-1)!.categories[0].items; + +describe('Adding several statistics', () => { + test('"Add" opens the "Choose Statistics" dialog with the default statistics', async () => { + const { user } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + const dialog = await openChooseStatisticsDialog(user); + + statsToAdd.forEach(({ label }) => + expect(queryStatButton(dialog, label)).toBeDefined() + ); + }); + + test('adds each chosen statistic to the end of the category', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + + for (const [index, stat] of statsToAdd.entries()) { + await addStat(user, stat); + expect( + screen.queryByRole('dialog', { name: statsText.chooseStatistics() }) + ).not.toBeInTheDocument(); + expect( + getCategoryItems(getSharedLayout).map( + (item) => + item.type === 'DefaultStat' && [item.categoryName, item.itemName] + ) + ).toEqual( + statsToAdd + .slice(0, index + 1) + .map(({ categoryName, itemName }) => [categoryName, itemName]) + ); + } + }); + + test('a statistic already on the page is no longer offered', async () => { + const { user } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + await addStat(user, statsToAdd[0]); + + const dialog = await openChooseStatisticsDialog(user); + expect(queryStatButton(dialog, statsToAdd[0].label)).toBeUndefined(); + expect(queryStatButton(dialog, statsToAdd[1].label)).toBeDefined(); + }); +}); From c71a7268df2074b0328a38d68f6754a64cb9f7c2 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 11:21:22 -0500 Subject: [PATCH 18/24] (test): create a new statistic --- .../__tests__/StatsPageAddStatistics.test.tsx | 38 +----- .../StatsPageCreateStatistic.test.tsx | 123 ++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 29 +++++ 3 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateStatistic.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx index 1e75df3ddc2..1179ee7ad71 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx @@ -1,27 +1,21 @@ import { screen, within } from '@testing-library/react'; -import { commonText } from '../../../localization/common'; import { statsText } from '../../../localization/stats'; -import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import { defaultLayoutGenerated } from '../StatsSpec'; -import type { RA } from '../../../utils/types'; -import type { DefaultStat, StatLayout } from '../types'; +import type { DefaultStat } from '../types'; import { - addSharedPage, - getCategoryInputs, + addPageWithCategory, + getCategoryItems, + mockSavedQueries, mockStatsRequests, + openChooseStatisticsDialog, renderEmptyStatsPage, type User, } from './statsPageTestUtils'; requireContext(); mockStatsRequests(); -overrideAjax( - '/api/specify/spquery/?limit=10&specifyuser=2&offset=0&orderby=-name', - { objects: [], meta: { limit: 10, offset: 0, total_count: 0 } } -); - -const newCategoryName = 'My Stats'; +mockSavedQueries(); const findDefaultStat = (category: string, item: string): DefaultStat => defaultLayoutGenerated[0].categories @@ -39,23 +33,6 @@ const statsToAdd = [ findDefaultStat('locality_geography', 'localityCount'), ]; -async function addPageWithCategory(user: User): Promise { - await addSharedPage(user, 'Stats Page'); - await user.click( - screen.getByRole('button', { name: statsText.addACategory() }) - ); - await user.type(getCategoryInputs()[0], newCategoryName); -} - -async function openChooseStatisticsDialog(user: User): Promise { - await user.click( - within(getCategoryInputs()[0].closest('li')!).getByRole('button', { - name: commonText.add(), - }) - ); - return screen.findByRole('dialog', { name: statsText.chooseStatistics() }); -} - const queryStatButton = (dialog: HTMLElement, label: string) => within(dialog) .queryAllByRole('button') @@ -66,9 +43,6 @@ async function addStat(user: User, stat: DefaultStat): Promise { await user.click(queryStatButton(dialog, stat.label)!); } -const getCategoryItems = (getSharedLayout: () => RA | undefined) => - getSharedLayout()!.at(-1)!.categories[0].items; - describe('Adding several statistics', () => { test('"Add" opens the "Choose Statistics" dialog with the default statistics', async () => { const { user } = await renderEmptyStatsPage(); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateStatistic.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateStatistic.test.tsx new file mode 100644 index 00000000000..250a942c448 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageCreateStatistic.test.tsx @@ -0,0 +1,123 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { queryText } from '../../../localization/query'; +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { tables } from '../../DataModel/tables'; +import type { CustomStat } from '../types'; +import { + addPageWithCategory, + getCategoryItems, + mockSavedQueries, + mockStatsRequests, + openChooseStatisticsDialog, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +jest.mock('../../QueryBuilder/Wrapped', () => { + const React = jest.requireActual('react'); + return { + QueryBuilder: ({ + onChange: handleChange, + }: { + readonly onChange?: (query: { + readonly fields: RA; + readonly isDistinct: boolean; + }) => void; + }) => + React.createElement( + 'button', + { + type: 'button', + onClick: () => + handleChange?.({ + fields: jest + .requireActual('../hooks') + .makeSerializedFieldsFromPaths('Locality', [ + { path: 'localityName' }, + ]), + isDistinct: false, + }), + }, + 'Add a field' + ), + }; +}); + +requireContext(); +mockStatsRequests(); +mockSavedQueries(); + +async function openNewQueryDialog(user: User): Promise { + await addPageWithCategory(user); + const chooseDialog = await openChooseStatisticsDialog(user); + await user.click( + within(chooseDialog).getByRole('button', { name: commonText.new() }) + ); + const tablesDialog = screen.getByRole('dialog', { + name: queryText.newQueryName(), + }); + await user.click( + within(tablesDialog).getByRole('button', { + name: new RegExp(`${tables.Locality.label}$`, 'u'), + }) + ); + return screen.getByRole('dialog', { name: queryText.newQueryName() }); +} + +describe('Creating a new statistic', () => { + test('Save is disabled until the query has a field', async () => { + const { user } = await renderEmptyStatsPage(); + const dialog = await openNewQueryDialog(user); + const save = within(dialog).getByRole('button', { + name: commonText.save(), + }); + + expect(save).toBeDisabled(); + await user.click( + within(dialog).getByRole('button', { name: 'Add a field' }) + ); + expect(save).toBeEnabled(); + }); + + test('saving adds the query as a new statistic in the category', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openNewQueryDialog(user); + await user.click( + within(dialog).getByRole('button', { name: 'Add a field' }) + ); + await user.click( + within(dialog).getByRole('button', { name: commonText.save() }) + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + const items = getCategoryItems(getSharedLayout); + expect(items).toHaveLength(1); + const stat = items[0] as CustomStat; + expect(stat).toMatchObject({ + type: 'CustomStat', + label: queryText.newQueryName(), + querySpec: { tableName: 'Locality' }, + }); + expect(stat.querySpec.fields.map(({ path }) => path)).toEqual([ + 'localityName', + ]); + await waitFor(() => + expect(getCategoryItems(getSharedLayout)[0].itemValue).toBeDefined() + ); + }); + + test('closing the query dialog does not add a statistic', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const dialog = await openNewQueryDialog(user); + await user.click( + within(dialog).getByRole('button', { name: 'Add a field' }) + ); + await user.click( + within(dialog).getByRole('button', { name: commonText.close() }) + ); + + expect(getCategoryItems(getSharedLayout)).toEqual([]); + }); +}); 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 4ee341c2d1a..ff392630512 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -68,6 +68,12 @@ export function mockStatsRequests(): void { ); } +export const mockSavedQueries = (): void => + overrideAjax( + '/api/specify/spquery/?limit=10&specifyuser=2&offset=0&orderby=-name', + { objects: [], meta: { limit: 10, offset: 0, total_count: 0 } } + ); + const makeEmptyPreferences = ( definitions: DEFINITIONS ) => @@ -220,3 +226,26 @@ export const getCategoryInputs = (): RA => screen .getAllByRole('textbox') .filter((input) => input.closest('ul') === null); + +export async function addPageWithCategory(user: User): Promise { + await addSharedPage(user, 'Stats Page'); + await user.click( + screen.getByRole('button', { name: statsText.addACategory() }) + ); + await user.type(getCategoryInputs()[0], 'My Stats'); +} + +export async function openChooseStatisticsDialog( + user: User +): Promise { + await user.click( + within(getCategoryInputs()[0].closest('li')!).getByRole('button', { + name: commonText.add(), + }) + ); + return screen.findByRole('dialog', { name: statsText.chooseStatistics() }); +} + +export const getCategoryItems = ( + getSharedLayout: () => RA | undefined +) => getSharedLayout()!.at(-1)!.categories[0].items; From bb5a657938960c51a0956850ea54cdcdcc3d5a15 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 11:53:31 -0500 Subject: [PATCH 19/24] (test): add stat item with same name; rename stat item --- .../StatsPageDuplicateStatName.test.tsx | 78 +++++++++++++++++++ .../__tests__/StatsPageRenameStat.test.tsx | 57 ++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 9 ++- 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx new file mode 100644 index 00000000000..a6ea62f017d --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx @@ -0,0 +1,78 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import { requireContext } from '../../../tests/helpers'; +import { + addPageWithCategory, + getCategoryItems, + getStatInputs, + mockSavedQueries, + mockStatsRequests, + openChooseStatisticsDialog, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +const queryName = 'My Saved Query'; + +requireContext(); +mockStatsRequests(); +mockSavedQueries([ + { + id: 1, + name: queryName, + contextname: 'Locality', + contexttableid: 2, + selectdistinct: false, + searchsynonymy: false, + countonly: false, + timestampcreated: '2026-01-01T00:00:00', + fields: [], + resource_uri: '/api/specify/spquery/1/', + }, +]); + +async function addSavedQuery(user: User): Promise { + const dialog = await openChooseStatisticsDialog(user); + await user.click( + await within(dialog).findByRole('button', { + name: new RegExp(`${queryName}$`, 'u'), + }) + ); +} + +describe('Adding a stat item with the same name', () => { + test('the second copy is named with "(2)"', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + await addSavedQuery(user); + await addSavedQuery(user); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(getCategoryItems(getSharedLayout).map(({ label }) => label)).toEqual( + [queryName, `${queryName} (2)`] + ); + expect(getStatInputs(0).map(({ value }) => value)).toEqual([ + queryName, + `${queryName} (2)`, + ]); + await waitFor(() => + getCategoryItems(getSharedLayout).forEach((item) => + expect(item.itemValue).toBeDefined() + ) + ); + }); + + test('a third copy is named with "(3)"', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + for (let index = 0; index < 3; index += 1) await addSavedQuery(user); + + expect(getCategoryItems(getSharedLayout).map(({ label }) => label)).toEqual( + [queryName, `${queryName} (2)`, `${queryName} (3)`] + ); + await waitFor(() => + getCategoryItems(getSharedLayout).forEach((item) => + expect(item.itemValue).toBeDefined() + ) + ); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx new file mode 100644 index 00000000000..ee240ad9549 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx @@ -0,0 +1,57 @@ +import { screen } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { requireContext } from '../../../tests/helpers'; +import { + enterEditMode, + getStatInputs, + mockStatsRequests, + renderEmptyStatsPage, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); + +const newName = 'Renamed Stat'; + +describe('Renaming a stat item', () => { + test('edit mode shows each stat name in an editable field', async () => { + const { user, sharedLayout } = await renderEmptyStatsPage(); + await enterEditMode(user); + + expect(getStatInputs(0).map(({ value }) => value)).toEqual( + sharedLayout[0].categories[0].items.map(({ label }) => label) + ); + }); + + test('typing a new name renames only that stat', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + const input = getStatInputs(0)[0]; + await user.clear(input); + await user.type(input, newName); + + expect(input).toHaveValue(newName); + const [renamed, ...others] = getSharedLayout()![0].categories[0].items; + const [original, ...originalOthers] = sharedLayout[0].categories[0].items; + expect(renamed).toEqual({ ...original, label: newName }); + expect(others).toEqual(originalOthers); + expect(getSharedLayout()![0].categories.slice(1)).toEqual( + sharedLayout[0].categories.slice(1) + ); + }); + + test('Cancel restores the original name', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + const input = getStatInputs(0)[0]; + await user.clear(input); + await user.type(input, newName); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + expect(getSharedLayout()![0].categories).toEqual( + sharedLayout[0].categories + ); + }); +}); 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 ff392630512..f8f86d7c70e 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -68,10 +68,10 @@ export function mockStatsRequests(): void { ); } -export const mockSavedQueries = (): void => +export const mockSavedQueries = (objects: RA> = []): void => overrideAjax( '/api/specify/spquery/?limit=10&specifyuser=2&offset=0&orderby=-name', - { objects: [], meta: { limit: 10, offset: 0, total_count: 0 } } + { objects, meta: { limit: 10, offset: 0, total_count: objects.length } } ); const makeEmptyPreferences = ( @@ -249,3 +249,8 @@ export async function openChooseStatisticsDialog( export const getCategoryItems = ( getSharedLayout: () => RA | undefined ) => getSharedLayout()!.at(-1)!.categories[0].items; + +export const getStatInputs = (categoryIndex: number): RA => + within(getCategoryInputs()[categoryIndex].closest('li')!) + .getAllByRole('textbox') + .filter((input) => input.closest('ul') !== null); From e71bde946bfb75259f5f55395460c0fa9fe9febf Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 12:15:57 -0500 Subject: [PATCH 20/24] (test): save new statistic --- .../StatsPageDuplicateStatName.test.tsx | 19 +++++++++++++++++++ .../__tests__/StatsPageRenameStat.test.tsx | 17 +++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 17 +++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx index a6ea62f017d..5e308894fa6 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDuplicateStatName.test.tsx @@ -8,6 +8,7 @@ import { mockStatsRequests, openChooseStatisticsDialog, renderEmptyStatsPage, + saveAndReopenEditing, type User, } from './statsPageTestUtils'; @@ -75,4 +76,22 @@ describe('Adding a stat item with the same name', () => { ) ); }); + + test('Save keeps both copies', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + await addSavedQuery(user); + await addSavedQuery(user); + await waitFor(() => + getCategoryItems(getSharedLayout).forEach((item) => + expect(item.itemValue).toBeDefined() + ) + ); + await saveAndReopenEditing(user); + + expect(getCategoryItems(getSharedLayout).map(({ label }) => label)).toEqual( + [queryName, `${queryName} (2)`] + ); + expect(screen.getByText(`${queryName} (2)`)).toBeInTheDocument(); + }); }); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx index ee240ad9549..a8365ad5c7f 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageRenameStat.test.tsx @@ -6,6 +6,7 @@ import { getStatInputs, mockStatsRequests, renderEmptyStatsPage, + saveAndReopenEditing, } from './statsPageTestUtils'; requireContext(); @@ -54,4 +55,20 @@ describe('Renaming a stat item', () => { sharedLayout[0].categories ); }); + + test('Save keeps the new name', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + const input = getStatInputs(0)[0]; + await user.clear(input); + await user.type(input, newName); + await saveAndReopenEditing(user); + + expect(getSharedLayout()![0].categories[0].items[0]).toEqual({ + ...sharedLayout[0].categories[0].items[0], + label: newName, + }); + expect(screen.getByText(newName)).toBeInTheDocument(); + }); }); 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 f8f86d7c70e..f5d9c88b83c 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -254,3 +254,20 @@ export const getStatInputs = (categoryIndex: number): RA => within(getCategoryInputs()[categoryIndex].closest('li')!) .getAllByRole('textbox') .filter((input) => input.closest('ul') !== null); + +export async function saveAndReopenEditing(user: User): Promise { + const collectionSync = jest.spyOn(collectionPreferences, 'awaitSynced'); + const userSync = jest.spyOn(userPreferences, 'awaitSynced'); + await user.click(screen.getByRole('button', { name: commonText.save() })); + + expect( + screen.queryByRole('button', { name: commonText.cancel() }) + ).not.toBeInTheDocument(); + expect(collectionSync).toHaveBeenCalled(); + expect(userSync).toHaveBeenCalled(); + collectionSync.mockRestore(); + userSync.mockRestore(); + + await enterEditMode(user); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); +} From d403cfdd268ae09080857da45cb3fe2a6da489d3 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 13:41:05 -0500 Subject: [PATCH 21/24] (test): delete new statistic item --- .../__tests__/StatsPageAddStatistics.test.tsx | 28 +------ .../__tests__/StatsPageDeleteNewStat.test.tsx | 81 +++++++++++++++++++ .../__tests__/statsPageTestUtils.tsx | 20 +++++ 3 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx index 1179ee7ad71..3fa28222e13 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageAddStatistics.test.tsx @@ -1,48 +1,28 @@ -import { screen, within } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { statsText } from '../../../localization/stats'; import { requireContext } from '../../../tests/helpers'; -import { defaultLayoutGenerated } from '../StatsSpec'; -import type { DefaultStat } from '../types'; import { addPageWithCategory, + addStat, + findDefaultStat, getCategoryItems, mockSavedQueries, mockStatsRequests, openChooseStatisticsDialog, + queryStatButton, renderEmptyStatsPage, - type User, } from './statsPageTestUtils'; requireContext(); mockStatsRequests(); mockSavedQueries(); -const findDefaultStat = (category: string, item: string): DefaultStat => - defaultLayoutGenerated[0].categories - .flatMap(({ items }) => items) - .find( - (stat): stat is DefaultStat => - stat.type === 'DefaultStat' && - stat.categoryName === category && - stat.itemName === item - )!; - const statsToAdd = [ findDefaultStat('holdings', 'specimens'), findDefaultStat('loans', 'openLoansCount'), findDefaultStat('locality_geography', 'localityCount'), ]; -const queryStatButton = (dialog: HTMLElement, label: string) => - within(dialog) - .queryAllByRole('button') - .find((button) => button.firstElementChild?.textContent === label); - -async function addStat(user: User, stat: DefaultStat): Promise { - const dialog = await openChooseStatisticsDialog(user); - await user.click(queryStatButton(dialog, stat.label)!); -} - describe('Adding several statistics', () => { test('"Add" opens the "Choose Statistics" dialog with the default statistics', async () => { const { user } = await renderEmptyStatsPage(); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx new file mode 100644 index 00000000000..fcc9dd6e00e --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx @@ -0,0 +1,81 @@ +import { waitFor, within } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import type { StatLayout } from '../types'; +import { + addPageWithCategory, + addStat, + findDefaultStat, + getCategoryInputs, + getCategoryItems, + getStatInputs, + mockSavedQueries, + mockStatsRequests, + openChooseStatisticsDialog, + queryStatButton, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); +mockSavedQueries(); + +const firstStat = findDefaultStat('holdings', 'specimens'); +const secondStat = findDefaultStat('loans', 'openLoansCount'); + +async function addTwoStats( + user: User, + getSharedLayout: () => RA | undefined +): Promise { + await addPageWithCategory(user); + await addStat(user, firstStat); + await addStat(user, secondStat); + await waitFor(() => + getCategoryItems(getSharedLayout).forEach((item) => + expect(item.itemValue).toBeDefined() + ) + ); +} + +const getRemoveButtons = () => + within(getCategoryInputs()[0].closest('li')!).getAllByRole('button', { + name: commonText.remove(), + }); + +describe('Deleting a new stat item', () => { + test('the trash button removes only that stat', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addTwoStats(user, getSharedLayout); + await user.click(getRemoveButtons()[0]); + + expect(getCategoryItems(getSharedLayout).map(({ label }) => label)).toEqual( + [secondStat.label] + ); + expect(getStatInputs(0).map(({ value }) => value)).toEqual([ + secondStat.label, + ]); + }); + + test('deleting every stat leaves an empty category', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addTwoStats(user, getSharedLayout); + await user.click(getRemoveButtons()[0]); + await user.click(getRemoveButtons()[0]); + + expect(getSharedLayout()!.at(-1)!.categories).toEqual([ + { label: 'My Stats', items: [] }, + ]); + }); + + test('a deleted stat is offered again in "available default statistics"', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await addTwoStats(user, getSharedLayout); + await user.click(getRemoveButtons()[0]); + + const dialog = await openChooseStatisticsDialog(user); + expect(queryStatButton(dialog, firstStat.label)).toBeDefined(); + expect(queryStatButton(dialog, secondStat.label)).toBeUndefined(); + }); +}); 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 f5d9c88b83c..deadfa4d398 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -271,3 +271,23 @@ export async function saveAndReopenEditing(user: User): Promise { await enterEditMode(user); await user.click(screen.getByRole('button', { name: commonText.cancel() })); } + +export const findDefaultStat = (category: string, item: string): DefaultStat => + defaultLayoutGenerated[0].categories + .flatMap(({ items }) => items) + .find( + (stat): stat is DefaultStat => + stat.type === 'DefaultStat' && + stat.categoryName === category && + stat.itemName === item + )!; + +export const queryStatButton = (dialog: HTMLElement, label: string) => + within(dialog) + .queryAllByRole('button') + .find((button) => button.firstElementChild?.textContent === label); + +export async function addStat(user: User, stat: DefaultStat): Promise { + const dialog = await openChooseStatisticsDialog(user); + await user.click(queryStatButton(dialog, stat.label)!); +} From 13519c7076deed81094e45b5abf45284fa467a0b Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 23 Oct 2026 14:26:16 -0500 Subject: [PATCH 22/24] (test): delete existing statistics item --- .../StatsPageDeleteExistingStat.test.tsx | 70 +++++++++++++++++++ .../__tests__/StatsPageDeleteNewStat.test.tsx | 18 ++--- .../__tests__/statsPageTestUtils.tsx | 6 ++ 3 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteExistingStat.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteExistingStat.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteExistingStat.test.tsx new file mode 100644 index 00000000000..e0115392deb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteExistingStat.test.tsx @@ -0,0 +1,70 @@ +import { screen } from '@testing-library/react'; +import { commonText } from '../../../localization/common'; +import { requireContext } from '../../../tests/helpers'; +import { + enterEditMode, + getRemoveButtons, + getStatInputs, + mockSavedQueries, + mockStatsRequests, + openChooseStatisticsDialog, + queryStatButton, + renderEmptyStatsPage, + saveAndReopenEditing, +} from './statsPageTestUtils'; + +requireContext(); +mockStatsRequests(); +mockSavedQueries(); + +describe('Deleting an existing stat item', () => { + test('the trash button removes only that stat', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + const [deleted, ...remaining] = sharedLayout[0].categories[0].items; + await enterEditMode(user); + await user.click(getRemoveButtons(0)[0]); + + expect(getSharedLayout()![0].categories[0].items).toEqual(remaining); + expect(getSharedLayout()![0].categories.slice(1)).toEqual( + sharedLayout[0].categories.slice(1) + ); + expect(getStatInputs(0).map(({ value }) => value)).not.toContain( + deleted.label + ); + }); + + test('Cancel brings the stat back', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + await enterEditMode(user); + await user.click(getRemoveButtons(0)[0]); + await user.click(screen.getByRole('button', { name: commonText.cancel() })); + + expect(getSharedLayout()![0].categories).toEqual( + sharedLayout[0].categories + ); + }); + + test('Save keeps the stat deleted', async () => { + const { user, sharedLayout, getSharedLayout } = + await renderEmptyStatsPage(); + const [, ...remaining] = sharedLayout[0].categories[0].items; + await enterEditMode(user); + await user.click(getRemoveButtons(0)[0]); + await saveAndReopenEditing(user); + + expect(getSharedLayout()![0].categories[0].items).toEqual(remaining); + }); + + test('a deleted stat is offered again in "available default statistics"', async () => { + const { user, sharedLayout } = await renderEmptyStatsPage(); + const [deleted, kept] = sharedLayout[0].categories[0].items; + await enterEditMode(user); + await user.click(getRemoveButtons(0)[0]); + + const dialog = await openChooseStatisticsDialog(user); + expect(queryStatButton(dialog, deleted.label)).toBeDefined(); + expect(queryStatButton(dialog, kept.label)).toBeUndefined(); + }); +}); diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx index fcc9dd6e00e..15b49cb3e40 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDeleteNewStat.test.tsx @@ -1,5 +1,4 @@ -import { waitFor, within } from '@testing-library/react'; -import { commonText } from '../../../localization/common'; +import { waitFor } from '@testing-library/react'; import { requireContext } from '../../../tests/helpers'; import type { RA } from '../../../utils/types'; import type { StatLayout } from '../types'; @@ -7,8 +6,8 @@ import { addPageWithCategory, addStat, findDefaultStat, - getCategoryInputs, getCategoryItems, + getRemoveButtons, getStatInputs, mockSavedQueries, mockStatsRequests, @@ -39,16 +38,11 @@ async function addTwoStats( ); } -const getRemoveButtons = () => - within(getCategoryInputs()[0].closest('li')!).getAllByRole('button', { - name: commonText.remove(), - }); - describe('Deleting a new stat item', () => { test('the trash button removes only that stat', async () => { const { user, getSharedLayout } = await renderEmptyStatsPage(); await addTwoStats(user, getSharedLayout); - await user.click(getRemoveButtons()[0]); + await user.click(getRemoveButtons(0)[0]); expect(getCategoryItems(getSharedLayout).map(({ label }) => label)).toEqual( [secondStat.label] @@ -61,8 +55,8 @@ describe('Deleting a new stat item', () => { test('deleting every stat leaves an empty category', async () => { const { user, getSharedLayout } = await renderEmptyStatsPage(); await addTwoStats(user, getSharedLayout); - await user.click(getRemoveButtons()[0]); - await user.click(getRemoveButtons()[0]); + await user.click(getRemoveButtons(0)[0]); + await user.click(getRemoveButtons(0)[0]); expect(getSharedLayout()!.at(-1)!.categories).toEqual([ { label: 'My Stats', items: [] }, @@ -72,7 +66,7 @@ describe('Deleting a new stat item', () => { test('a deleted stat is offered again in "available default statistics"', async () => { const { user, getSharedLayout } = await renderEmptyStatsPage(); await addTwoStats(user, getSharedLayout); - await user.click(getRemoveButtons()[0]); + await user.click(getRemoveButtons(0)[0]); const dialog = await openChooseStatisticsDialog(user); expect(queryStatButton(dialog, firstStat.label)).toBeDefined(); 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 deadfa4d398..488a627afae 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -291,3 +291,9 @@ export async function addStat(user: User, stat: DefaultStat): Promise { const dialog = await openChooseStatisticsDialog(user); await user.click(queryStatButton(dialog, stat.label)!); } + +export const getRemoveButtons = (categoryIndex: number): RA => + within(getCategoryInputs()[categoryIndex].closest('li')!).getAllByRole( + 'button', + { name: commonText.remove() } + ); From 6022b31227f7968ef78200cad5886bf806ca5d21 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 30 Oct 2026 08:09:16 -0500 Subject: [PATCH 23/24] (test): download as TSV; skip empty categories --- .../__tests__/StatsPageDownloadTsv.test.tsx | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx new file mode 100644 index 00000000000..997978049f3 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx @@ -0,0 +1,82 @@ +import { screen } from '@testing-library/react'; +import { statsText } from '../../../localization/stats'; +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { downloadFile } from '../../Molecules/FilePicker'; +import type { StatLayout } from '../types'; +import { + addPageWithCategory, + enterEditMode, + getCategoryInputs, + mockStatsRequests, + renderEmptyStatsPage, + type User, +} from './statsPageTestUtils'; + +jest.mock('../../Molecules/FilePicker', () => ({ + ...jest.requireActual('../../Molecules/FilePicker'), + downloadFile: jest.fn(async () => undefined), +})); + +requireContext(); +mockStatsRequests(); + +const header = [ + statsText.categoryName(), + statsText.itemName(), + statsText.itemValue(), +].join('\t'); + +async function downloadTsv(user: User): Promise> { + jest.mocked(downloadFile).mockClear(); + await user.click( + screen.getByRole('button', { name: statsText.downloadAsTSV() }) + ); + expect(downloadFile).toHaveBeenCalledTimes(1); + return (jest.mocked(downloadFile).mock.calls[0][1] as string).split('\n'); +} + +const rowsFor = (page: StatLayout): RA => + page.categories.flatMap(({ label, items }) => + items + .filter(({ itemValue }) => itemValue !== undefined) + .map((item) => [label, item.label, item.itemValue].join('\t')) + ); + +describe('Downloading the statistics as a TSV', () => { + test('exports one row per loaded statistic on the open page', async () => { + const { user, sharedLayout } = await renderEmptyStatsPage(); + const lines = await downloadTsv(user); + + expect(lines[0]).toBe(header); + expect(lines.slice(1)).toEqual(rowsFor(sharedLayout[0])); + expect(lines).toContain( + `${sharedLayout[0].categories[1].label}\tC&S\t826 / 430` + ); + }); + + test('skips empty categories', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + await enterEditMode(user); + await user.click( + screen.getByRole('button', { name: statsText.addACategory() }) + ); + await user.type(getCategoryInputs().at(-1)!, 'Empty Category'); + const lines = await downloadTsv(user); + + expect(getSharedLayout()![0].categories.at(-1)?.label).toBe( + 'Empty Category' + ); + expect(lines.some((line) => line.startsWith('Empty Category\t'))).toBe( + false + ); + expect(lines.slice(1)).toEqual(rowsFor(getSharedLayout()![0])); + }); + + test('a page with only an empty category exports just the header', async () => { + const { user } = await renderEmptyStatsPage(); + await addPageWithCategory(user); + + expect(await downloadTsv(user)).toEqual([header]); + }); +}); From 891e493480d050477e04554bc9dcc3aa5aa58412 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 30 Oct 2026 08:25:14 -0500 Subject: [PATCH 24/24] (test): items with no permission are skipped on TSV download --- .../__tests__/StatsPageDownloadTsv.test.tsx | 77 ++++++++++++++++++- .../__tests__/statsPageTestUtils.tsx | 16 +++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx index 997978049f3..1b471c2f2e7 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/StatsPageDownloadTsv.test.tsx @@ -1,23 +1,39 @@ -import { screen } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import { statsText } from '../../../localization/stats'; +import { userText } from '../../../localization/user'; +import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; +import { Http } from '../../../utils/ajax/definitions'; import type { RA } from '../../../utils/types'; import { downloadFile } from '../../Molecules/FilePicker'; import type { StatLayout } from '../types'; import { addPageWithCategory, enterEditMode, + findDefaultStat, getCategoryInputs, mockStatsRequests, renderEmptyStatsPage, type User, } from './statsPageTestUtils'; +const mockDeniedTables = new Set(); + jest.mock('../../Molecules/FilePicker', () => ({ ...jest.requireActual('../../Molecules/FilePicker'), downloadFile: jest.fn(async () => undefined), })); +jest.mock('../../Permissions/helpers', () => { + const actual = jest.requireActual('../../Permissions/helpers'); + return { + ...actual, + hasTablePermission: (tableName: string, ...rest: RA) => + !mockDeniedTables.has(tableName) && + actual.hasTablePermission(tableName, ...rest), + }; +}); + requireContext(); mockStatsRequests(); @@ -36,6 +52,14 @@ async function downloadTsv(user: User): Promise> { return (jest.mocked(downloadFile).mock.calls[0][1] as string).split('\n'); } +const getStatButton = (label: string): HTMLElement => + screen + .getAllByRole('button') + .find((button) => button.firstElementChild?.textContent === label)!; + +const hasRowFor = (lines: RA, label: string): boolean => + lines.some((line) => line.split('\t')[1] === label); + const rowsFor = (page: StatLayout): RA => page.categories.flatMap(({ label, items }) => items @@ -80,3 +104,54 @@ describe('Downloading the statistics as a TSV', () => { expect(await downloadTsv(user)).toEqual([header]); }); }); + +describe('Exporting statistics shown with NO PERMISSION', () => { + const deniedStats = [ + findDefaultStat('loans', 'openLoansCount'), + findDefaultStat('loans', 'overdueLoansCount'), + ]; + + beforeAll(() => mockDeniedTables.add('Loan')); + afterAll(() => mockDeniedTables.clear()); + + test('query stats on tables the user cannot read are skipped', async () => { + const { user, sharedLayout } = await renderEmptyStatsPage(); + deniedStats.forEach(({ label }) => + expect( + within(getStatButton(label)).getByText(userText.noPermission()) + ).toBeInTheDocument() + ); + const lines = await downloadTsv(user); + + deniedStats.forEach(({ label }) => + expect(hasRowFor(lines, label)).toBe(false) + ); + expect(lines.slice(1)).toEqual(rowsFor(sharedLayout[0])); + }); +}); + +describe('Exporting backend statistics the user is forbidden to see', () => { + overrideAjax( + '/stats/collection/locality_geography/percentGeoReferenced/', + {}, + { responseCode: Http.FORBIDDEN }, + true + ); + + test('backend stats that return 403 are skipped', async () => { + const { user, getSharedLayout } = await renderEmptyStatsPage(); + const { label } = findDefaultStat( + 'locality_geography', + 'percentGeoReferenced' + ); + await waitFor(() => + expect( + within(getStatButton(label)).getByText(userText.noPermission()) + ).toBeInTheDocument() + ); + const lines = await downloadTsv(user); + + expect(hasRowFor(lines, label)).toBe(false); + expect(lines.slice(1)).toEqual(rowsFor(getSharedLayout()![0])); + }); +}); 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 488a627afae..d184dcd41ce 100644 --- a/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx +++ b/specifyweb/frontend/js_src/lib/components/Statistics/__tests__/statsPageTestUtils.tsx @@ -13,9 +13,11 @@ import { collectionPreferences } from '../../Preferences/collectionPreferences'; import { userPreferenceDefinitions } from '../../Preferences/UserDefinitions'; import { userPreferences } from '../../Preferences/userPreferences'; import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { getNoAccessTables } from '../../QueryBuilder/helpers'; import { getBackendUrlToFetch, getDynamicQuerySpecsToFetch, + makeSerializedFieldsFromPaths, resolveStatsSpec, } from '../hooks'; import { StatsPage } from '../index'; @@ -97,6 +99,18 @@ const getLayout = ( const isQueryStat = (item: CustomStat | DefaultStat): boolean => item.type === 'CustomStat' || item.itemType === 'QueryStat'; +const canRead = (item: CustomStat | DefaultStat): boolean => { + const querySpec = resolveStatsSpec(item, { + showPreparationsTotal: true, + })?.querySpec; + return ( + querySpec === undefined || + getNoAccessTables( + makeSerializedFieldsFromPaths(querySpec.tableName, querySpec.fields) + ).length === 0 + ); +}; + export async function waitForInitialLoad( getSharedLayout: () => RA | undefined ): Promise> { @@ -108,7 +122,7 @@ export async function waitForInitialLoad( expect(getDynamicQuerySpecsToFetch(layout!)).toEqual([]); layout![0].categories .flatMap(({ items }) => items) - .filter(isQueryStat) + .filter((item) => isQueryStat(item) && canRead(item)) .forEach((item) => expect(item.itemValue).toBeDefined()); }); return getSharedLayout()!;