From ab97fee8a91f5760867dea0a7cec624181abf7f0 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 4 Sep 2026 14:16:10 -0500 Subject: [PATCH 01/28] [test]: rough sketch of unit test --- .../WbUtils/__tests__/datasetVariants.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts new file mode 100644 index 00000000000..f6138d54fdb --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -0,0 +1,10 @@ +import { hasPermission } from '../../Permissions/helpers'; +import { userPreferences } from '../../Preferences/userPreferences'; +import { datasetVariants } from '../datasetVariants'; + +jest.mock('../../Permissions/helpers', () => ({ + hasPermission: jest.fn(), +})); + +const mockedHasPermission = hasPermission as jest.Mock; + From f31587bf76d2a6f3cc460c9122a85b1f5480161e Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:29:23 +0200 Subject: [PATCH 02/28] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index f6138d54fdb..a11a8efec53 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -1,5 +1,4 @@ import { hasPermission } from '../../Permissions/helpers'; -import { userPreferences } from '../../Preferences/userPreferences'; import { datasetVariants } from '../datasetVariants'; jest.mock('../../Permissions/helpers', () => ({ From 57fb8d5148ecf36f86c8906947fc20e83b6ebb3f Mon Sep 17 00:00:00 2001 From: "Caroline D." <108160931+CarolineDenis@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:30:34 +0200 Subject: [PATCH 03/28] Potential fix for pull request finding 'CodeQL / Unused variable, import, function or class' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index a11a8efec53..cc8720b9213 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -5,5 +5,3 @@ jest.mock('../../Permissions/helpers', () => ({ hasPermission: jest.fn(), })); -const mockedHasPermission = hasPermission as jest.Mock; - From d6188dc51f0397be17d9564b6d8e86dd4503d77d Mon Sep 17 00:00:00 2001 From: kwhuber Date: Tue, 8 Sep 2026 15:15:23 -0500 Subject: [PATCH 04/28] [test]: WIP of disable relationships for batch edit and enable rollback in user preferences --- .../WbUtils/__tests__/datasetVariants.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index f6138d54fdb..426477af43f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -8,3 +8,15 @@ jest.mock('../../Permissions/helpers', () => ({ const mockedHasPermission = hasPermission as jest.Mock; +const setBatchEditPreferences = ( + enableRelationships: boolean, + showRollback: boolean +): void => { + userPreferences.set( + 'batchEdit', + 'editor', + 'enableRelationships', + enableRelationships + ); + userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback); +}; \ No newline at end of file From 341e3e54a4e8472e658cbe2c855846abf5bce7ad Mon Sep 17 00:00:00 2001 From: kwhuber Date: Tue, 8 Sep 2026 16:19:15 -0500 Subject: [PATCH 05/28] test commit --- .../lib/components/WbUtils/__tests__/datasetVariants.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index 426477af43f..0cca996878c 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -1,3 +1,4 @@ +// test import { hasPermission } from '../../Permissions/helpers'; import { userPreferences } from '../../Preferences/userPreferences'; import { datasetVariants } from '../datasetVariants'; @@ -19,4 +20,4 @@ const setBatchEditPreferences = ( enableRelationships ); userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback); -}; \ No newline at end of file +}; From 0cc0e4569a6a031bd39d5cb5b020a3a633fc9ac6 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 10 Sep 2026 17:13:42 -0500 Subject: [PATCH 06/28] more WIP of disable relationships for batch edit and enable rollback in user preferences --- .../WbUtils/__tests__/datasetVariants.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index 0cca996878c..79d9fd7f0a8 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -1,4 +1,3 @@ -// test import { hasPermission } from '../../Permissions/helpers'; import { userPreferences } from '../../Preferences/userPreferences'; import { datasetVariants } from '../datasetVariants'; @@ -21,3 +20,43 @@ const setBatchEditPreferences = ( ); userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback); }; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('batch edit rollback availability', () => { + test.each([ + [false, true, true, true], + [true, true, true, false], + [false, false, true, false], + [false, true, false, false], + [true, false, true, false], + [true, true, false, false], + [false, false, false, false], + [true, false, false, false], + ])( + 'enableRelationships=%s showRollback=%s permission=%s -> canUndo=%s', + (enableRelationships, showRollback, permission, expected) => { + setBatchEditPreferences(enableRelationships, showRollback); + mockedHasPermission.mockReturnValue(permission); + + expect(datasetVariants.batchEdit.canUndo()).toBe(expected); + } + ); + + test('checks the batch edit rollback permission', () => { + setBatchEditPreferences(false, true); + mockedHasPermission.mockReturnValue(true); + + datasetVariants.batchEdit.canUndo(); + + expect(mockedHasPermission).toHaveBeenCalledWith( + '/batch_edit/dataset', + 'rollback' + ); + }); \ No newline at end of file From 1c3de4dfa757f24be0f765479805275384772db7 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 11 Sep 2026 09:47:24 -0500 Subject: [PATCH 07/28] [test]: disable relationships for batch edit and enable rollback in user preferences --- .../WbUtils/__tests__/datasetVariants.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index 79d9fd7f0a8..8e7af4fd268 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -59,4 +59,40 @@ describe('batch edit rollback availability', () => { '/batch_edit/dataset', 'rollback' ); - }); \ No newline at end of file + }); + + test('does not consult permissions when relationships are enabled', () => { + setBatchEditPreferences(true, true); + mockedHasPermission.mockReturnValue(true); + + expect(datasetVariants.batchEdit.canUndo()).toBe(false); + expect(mockedHasPermission).not.toHaveBeenCalled(); + }); + + test('rollback is hidden based off default preferences', () => { + expect( + userPreferences.definition('batchEdit', 'editor', 'enableRelationships') + .defaultValue + ).toBe(true); + expect( + userPreferences.definition('batchEdit', 'editor', 'showRollback') + .defaultValue + ).toBe(true); + + setBatchEditPreferences(true, true); + mockedHasPermission.mockReturnValue(true); + + expect(datasetVariants.batchEdit.canUndo()).toBe(false); + }); + + test('workbench rollback is not affected by batch edit preferences', () => { + setBatchEditPreferences(true, true); + mockedHasPermission.mockReturnValue(true); + + expect(datasetVariants.workbench.canUndo()).toBe(true); + expect(mockedHasPermission).toHaveBeenCalledWith( + '/workbench/dataset', + 'unupload' + ); + }); +}); \ No newline at end of file From 954e48058d664ca6dcfbcc9a96ebffab84c4f8b6 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 11 Sep 2026 10:16:48 -0500 Subject: [PATCH 08/28] [fix]: small change for BE relationships and rollback permissions --- .../WbUtils/__tests__/datasetVariants.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index 8e7af4fd268..8a52e434f7f 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -21,6 +21,9 @@ const setBatchEditPreferences = ( userPreferences.set('batchEdit', 'editor', 'showRollback', showRollback); }; +// Drop every explicitly set preference so lookups fall back to the defaults +const resetPreferences = (): void => userPreferences.setRaw({}); + beforeAll(() => { jest.useFakeTimers(); }); @@ -70,6 +73,8 @@ describe('batch edit rollback availability', () => { }); test('rollback is hidden based off default preferences', () => { + resetPreferences(); + expect( userPreferences.definition('batchEdit', 'editor', 'enableRelationships') .defaultValue @@ -79,10 +84,16 @@ describe('batch edit rollback availability', () => { .defaultValue ).toBe(true); - setBatchEditPreferences(true, true); - mockedHasPermission.mockReturnValue(true); + // Unset preferences resolve to the declared defaults + expect( + userPreferences.get('batchEdit', 'editor', 'enableRelationships') + ).toBe(true); + expect(userPreferences.get('batchEdit', 'editor', 'showRollback')).toBe( + true + ); expect(datasetVariants.batchEdit.canUndo()).toBe(false); + expect(mockedHasPermission).not.toHaveBeenCalled(); }); test('workbench rollback is not affected by batch edit preferences', () => { From 320ae37e9f2dc5034474394a534bd614f1ecf745 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 11 Sep 2026 12:27:59 -0500 Subject: [PATCH 09/28] [test]: WIP of create a query; very relationsips are not editable --- .../BatchEdit/__tests__/index.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts new file mode 100644 index 00000000000..21f43c77a05 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/index.test.ts @@ -0,0 +1,73 @@ +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { tables } from '../../DataModel/tables'; +import type { QueryField } from '../../QueryBuilder/helpers'; +import type { MappingPath } from '../../WbPlanView/Mapper'; +import { buildBatchEditFromQueryBody } from '../index'; + +requireContext(); + +const queryField = ( + mappingPath: MappingPath, + isDisplay: boolean = true +): QueryField => ({ + id: 0, + mappingPath, + sortType: undefined, + isDisplay, + filters: [], +}); + +/* A query over Collection Object that reaches through a to-one relationship + * (accession), a to-many one (accession agents), and an unchecked field. + */ +const fields: RA = [ + queryField(['catalogNumber']), + queryField(['guid'], false), // Unchecked in the query builder + queryField(['accession', 'accessionNumber']), + queryField(['accession', 'accessionAgents', '#1', 'role']), +]; + +const buildBody = ( + hasRelationships: boolean, + extraFields: RA = fields +) => + buildBatchEditFromQueryBody({ + query: new tables.SpQuery.Resource({ name: 'Test Query' }), + limit: 5000, + fields: extraFields, + baseTableName: 'CollectionObject', + dataSetName: 'Test Query - Fri Sep 11 2026', + recordSetId: 42, + treeDefsFilter: {}, + hasRelationships, + }); + +describe('buildBatchEditFromQueryBody', () => { + test('captions describe each displayed field with relationships', () => { + expect(buildBody(true).captions).toEqual([ + 'Cat #', + 'Accession #', + 'Accession Agents - Role', + ]); + }); + + test('fields hidden in the query builder get no caption', () => { + const captions = buildBody(true).captions; + expect(captions).toHaveLength(3); + expect(captions).not.toContain('Collection Object - GUID'); + }); + + // Covers front end half of verifying relationships are not editable. + test('omitRelationships is the inverse of the preference', () => { + expect(buildBody(true).omitrelationships).toBe(false); + expect(buildBody(false).omitrelationships).toBe(true); + }); + + test('carries the data set name, limit and record set through', () => { + const body = buildBody(true); + expect(body.name).toBe('Test Query - Fri Sep 11 2026'); + expect(body.limit).toBe(5000); + expect(body.recordsetid).toBe(42); + }); +}); From a316757487f387091819461cebeee72601e8f8eb Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 17 Sep 2026 15:11:39 -0500 Subject: [PATCH 10/28] [test]: WIP of 'test search and replace behavior' --- .../WbUtils/__tests__/Utils.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts new file mode 100644 index 00000000000..de46abf43e9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts @@ -0,0 +1,71 @@ +import type React from 'react'; +import type { RA } from '../../../utils/types'; +import type { Workbench } from '../../WorkBench/WbView'; +import { WbUtils } from '../Utils'; + +type Cell = { + readonly value: string; + readonly readOnly?: boolean; + readonly isSearchResult?: boolean; +}; + +const cell = (value: string, extra: Omit = {}): Cell => ({ + value, + isSearchResult: true, + ...extra, +}); + +function buildWorkbench( + grid: RA>, + selected: readonly [number, number] = [0, 0] +) { + const setDataAtCell = jest.fn(); + const at = (row: number, col: number): Cell | undefined => grid[row]?.[col]; + + const workbench = { + hot: { + toVisualRow: (row: number) => row, + toVisualColumn: (col: number) => col, + toPhysicalRow: (row: number) => row, + toPhysicalColumn: (col: number) => col, + getDataAtCell: (row: number, col: number) => at(row, col)?.value ?? '', + getCellMeta: (row: number, col: number) => ({ + readOnly: at(row, col)?.readOnly === true, + }), + getSelectedLast: () => selected, + setDataAtCell, + }, + cells: { + cellMeta: Object.fromEntries( + grid.map((row, rowIndex) => [ + rowIndex, + Object.fromEntries(row.map((cell, colIndex) => [colIndex, cell])), + ]) + ), + getCellMetaFromArray: (meta: Cell, key: string) => + key === 'isSearchResult' ? meta.isSearchResult === true : undefined, + cellIsType: (meta: Cell | undefined, type: string) => + type === 'searchResults' && meta?.isSearchResult === true, + // Navigating away is out of scope here, so there is nothing to walk + getCellMetaObject: () => [], + }, + }; + + return { setDataAtCell, workbench: workbench as unknown as Workbench }; +} + +const enterKey = { key: 'Enter' } as React.KeyboardEvent; +const replacement = { value: 'new' } as HTMLInputElement; + +const buildUtils = ( + workbench: Workbench, + replaceMode: 'replaceAll' | 'replaceNext' +): WbUtils => { + const utils = new WbUtils(workbench, { current: null }); + utils.searchQuery = 'old'; + utils.searchPreferences = { + ...utils.searchPreferences, + replace: { replaceMode }, + }; + return utils; +}; \ No newline at end of file From b39af5c741af19ef3cab6abb57de6884d394c770 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 18 Sep 2026 08:47:36 -0500 Subject: [PATCH 11/28] [test]: test search and replace behavior --- .../WbUtils/__tests__/Utils.test.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts index de46abf43e9..255443d8f54 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/Utils.test.ts @@ -46,7 +46,6 @@ function buildWorkbench( key === 'isSearchResult' ? meta.isSearchResult === true : undefined, cellIsType: (meta: Cell | undefined, type: string) => type === 'searchResults' && meta?.isSearchResult === true, - // Navigating away is out of scope here, so there is nothing to walk getCellMetaObject: () => [], }, }; @@ -68,4 +67,74 @@ const buildUtils = ( replace: { replaceMode }, }; return utils; -}; \ No newline at end of file +}; + +describe('replaceCells, replace all', () => { + test('replaces every editable cell that matched the search', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old'), cell('old')], + ]); + buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith([ + [0, 0, 'new'], + [0, 1, 'new'], + ]); + }); + + test('leaves read only cells alone', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old'), cell('old', { readOnly: true })], + ]); + buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]); + }); + + test('replaces nothing when every match is read only', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old', { readOnly: true }), cell('old', { readOnly: true })], + ]); + buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith([]); + }); + + test('skips cells that did not match the search', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old'), cell('other', { isSearchResult: false })], + ]); + buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]); + }); + + test('skips empty cells so defaults are not overwritten', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old'), cell('')], + ]); + buildUtils(workbench, 'replaceAll').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith([[0, 0, 'new']]); + }); + + test('ignores keys other than Enter', () => { + const { workbench, setDataAtCell } = buildWorkbench([[cell('old')]]); + buildUtils(workbench, 'replaceAll').replaceCells( + { key: 'a' } as React.KeyboardEvent, + replacement + ); + expect(setDataAtCell).not.toHaveBeenCalled(); + }); +}); + +describe('replaceCells, replace next', () => { + test('replaces the selected cell when it is editable', () => { + const { workbench, setDataAtCell } = buildWorkbench([[cell('old')]]); + buildUtils(workbench, 'replaceNext').replaceCells(enterKey, replacement); + expect(setDataAtCell).toHaveBeenCalledWith(0, 0, 'new'); + }); + + test('refuses to replace the selected cell when it is read only', () => { + const { workbench, setDataAtCell } = buildWorkbench([ + [cell('old', { readOnly: true })], + ]); + buildUtils(workbench, 'replaceNext').replaceCells(enterKey, replacement); + expect(setDataAtCell).not.toHaveBeenCalled(); + }); +}); From debdaf267782064728a9fa005687236c52933abc Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 18 Sep 2026 12:17:09 -0500 Subject: [PATCH 12/28] [test]: click Batch Edit & verify query needs to be saved before you can batch edit --- .../__tests__/BatchEditFromQuery.test.tsx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx new file mode 100644 index 00000000000..cadfd2e4dc4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { batchEditText } from '../../../localization/batchEdit'; +import { commonText } from '../../../localization/common'; +import { queryText } from '../../../localization/query'; +import { overrideAjax } from '../../../tests/ajax'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import type { RA } from '../../../utils/types'; +import { LoadingContext } from '../../Core/Contexts'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { tables } from '../../DataModel/tables'; +import type { QueryField } from '../../QueryBuilder/helpers'; +import type { MappingPath } from '../../WbPlanView/Mapper'; +import { BatchEditFromQuery } from '../index'; + +requireContext(); + +const datasetId = 7; + +async function withoutActWarnings( + callback: () => Promise +): Promise { + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + try { + await callback(); + } finally { + consoleError.mockRestore(); + } +} + +overrideAjax('/api/workbench/dataset/?isupdate=1', []); +overrideAjax('/stored_query/batch_edit/', { id: datasetId }, { + method: 'POST', +}); + +const queryField = (mappingPath: MappingPath): QueryField => ({ + id: 0, + mappingPath, + sortType: undefined, + isDisplay: true, + filters: [], +}); + +const buildQuery = (contextName: string = 'CollectionObject') => + new tables.SpQuery.Resource({ + name: 'Test Query', + contextName, + contextTableId: tables.CollectionObject.tableId, + }); + +function render({ + saveRequired = false, + baseTableName = 'CollectionObject' as const, + fields = [queryField(['catalogNumber'])] as RA, + contextName, + needsSaved = false, +}: { + readonly saveRequired?: boolean; + readonly baseTableName?: 'Collection' | 'CollectionObject'; + readonly fields?: RA; + readonly contextName?: string; + readonly needsSaved?: boolean; +} = {}) { + const query = buildQuery(contextName); + if (needsSaved) query.set('name', 'Edited but never saved'); + const handleLoading = (promise: Promise): void => { + void promise; + }; + return mount( + + + + + + } + path="/" + /> + Data set opened

} + path="/specify/workbench/:id" + /> +
+
+
+
+ ); +} + +describe('the Batch Edit button', () => { + test('is offered for a random query', () => { + const { getByRole } = render(); + expect( + getByRole('button', { name: batchEditText.batchEdit() }) + ).toBeEnabled(); + }); + + test('is disabled when the query is based on a hierarchy table', () => { + const { getByRole } = render({ + baseTableName: 'Collection', + fields: [queryField(['collectionName'])], + }); + const button = getByRole('button', { name: batchEditText.batchEdit() }); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('title', batchEditText.batchEditDisabled()); + }); + + test('is disabled for a query over the audit log', () => { + const { getByRole } = render({ contextName: 'SpAuditLog' }); + expect( + getByRole('button', { name: batchEditText.batchEdit() }) + ).toBeDisabled(); + }); + + test('creates the data set and opens it', async () => { + const { getByRole, findByText, user } = render(); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + expect(await findByText('Data set opened')).toBeInTheDocument(); + }); + }); +}); + +describe('the unsaved query guard', () => { + test('warns instead of batch editing when the query builder has changes', async () => { + const { getByRole, queryByText, user } = render({ saveRequired: true }); + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + expect(getByRole('dialog')).toHaveTextContent( + queryText.unsavedChangesInQuery() + ); + expect(queryByText('Data set opened')).toBeNull(); + }); + + test('warns when the query resource itself is unsaved', async () => { + const { getByRole, user } = render({ needsSaved: true }); + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + expect(getByRole('dialog')).toHaveTextContent( + queryText.unsavedChangesInQuery() + ); + }); + + test('the warning can be dismissed', async () => { + const { getByRole, queryByRole, user } = render({ saveRequired: true }); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + await user.click(getByRole('button', { name: commonText.close() })); + expect(queryByRole('dialog')).toBeNull(); + }); + }); +}); From 82e7f69db957f5ff98543b33b7f981162ab0cc0a Mon Sep 17 00:00:00 2001 From: kwhuber Date: Tue, 22 Sep 2026 15:09:17 -0500 Subject: [PATCH 13/28] [test]: verify relationships are not editable (backend) --- .../stored_queries/tests/test_batch_edit.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/specifyweb/backend/stored_queries/tests/test_batch_edit.py b/specifyweb/backend/stored_queries/tests/test_batch_edit.py index 477af0f5970..b2e70cbe36d 100644 --- a/specifyweb/backend/stored_queries/tests/test_batch_edit.py +++ b/specifyweb/backend/stored_queries/tests/test_batch_edit.py @@ -2342,4 +2342,55 @@ def test_column_key_collision(self): (headers, rows, packs, plan, order) = run_batch_edit_query(props) self.assertEqual(headers, expected_captions) + + # Tests to verify batch edit relationships are not editable. + def _make_determination(self): + return models.Determination.objects.create( + collectionobject=self.collectionobjects[0], + remarks="Remarks for collection object 1, det 1", + ) + + def _run_omitting_relationships(self, omit_relationships: bool): + base_table = "collectionobject" + query_paths = [ + ["catalognumber"], + ["cataloger", "firstname"], + ["determinations", "remarks"], + ] + added = [(base_table, *path) for path in query_paths] + query_fields = [ + BatchEditPack._query_field(QueryFieldSpec.from_path(path), 0) + for path in added + ] + props = self.build_props(query_fields, base_table) + props["omit_relationships"] = omit_relationships + (headers, rows, packs, plan, order) = run_batch_edit_query(props) + return headers, plan["uploadable"]["uploadTable"] + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) + def test_relationships_are_editable_by_default(self): + self._make_determination() + (headers, upload_table) = self._run_omitting_relationships(False) + self.assertIn("cataloger", upload_table["toOne"]) + self.assertIn("determinations", upload_table["toMany"]) + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) + def test_omitting_relationships_removes_them_from_the_upload_plan(self): + self._make_determination() + (headers, upload_table) = self._run_omitting_relationships(True) + self.assertEqual(upload_table["toOne"], {}) + self.assertEqual(upload_table["toMany"], {}) + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) + def test_base_table_fields_stay_editable_without_relationships(self): + self._make_determination() + (headers, upload_table) = self._run_omitting_relationships(True) + self.assertIn("catalognumber", upload_table["wbcols"]) + + @patch(OBJ_FORMATTER_PATH, new=fake_obj_formatter) + def test_relationship_columns_are_still_shown(self): + self._make_determination() + (shown_headers, _) = self._run_omitting_relationships(False) + (omitted_headers, _) = self._run_omitting_relationships(True) + self.assertEqual(omitted_headers, shown_headers) From 194dd873959a2ff57a289f8aa951d6d280ebea18 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 24 Sep 2026 12:52:30 -0500 Subject: [PATCH 14/28] [fix]: updated dependencies so debdaf2 should pass frontend tests --- .../BatchEdit/__tests__/BatchEditFromQuery.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx index cadfd2e4dc4..b21d15d2ecd 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx @@ -68,7 +68,10 @@ function render({ void promise; }; return mount( - + From 58c75dbf1e87395eb57d13e603eecda813f8a574 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 24 Sep 2026 14:25:26 -0500 Subject: [PATCH 15/28] [test]: verify you can roll back --- .../upload/tests/test_batch_edit_rollback.py | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py new file mode 100644 index 00000000000..1aac1ae38b2 --- /dev/null +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py @@ -0,0 +1,116 @@ +import json + +from specifyweb.backend.stored_queries.batch_edit import ( + make_dataset, + run_batch_edit_query, +) +from specifyweb.backend.stored_queries.queryfield import QueryField +from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec +from specifyweb.backend.stored_queries.tests.test_batch_edit import props_builder +from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup +from specifyweb.backend.workbench.models import Spdataset +from specifyweb.backend.workbench.tasks import unupload +from specifyweb.backend.workbench.upload.tests.base import UploadTestsBase +from specifyweb.backend.workbench.upload.upload import ( + do_upload_dataset, + rollback_batch_edit, +) +from specifyweb.backend.workbench.views import regularize_rows +from specifyweb.specify.models import Collectionobject + +""" +Creating a batch edit data set also creates a backup through the make_dataset +function. Rolling back (rollback_batch_edit) re-uploads the backedup values over +the committed records, then unuploads the data set +""" + +ORIGINAL_REMARKS = "Remarks before batch edit" +EDITED_REMARKS = "Remarks changed by batch edit" + +class BatchEditRollbackTests(SQLAlchemySetup, UploadTestsBase): + def setUp(self): + super().setUp() + self.build_props = props_builder( + self, BatchEditRollbackTests.test_session_context + ) + Collectionobject.objects.all().delete() + self.co = Collectionobject.objects.create( + catalognumber="7924".zfill(9), + remarks=ORIGINAL_REMARKS, + collection=self.collection, + ) + + def _query_field(self, path) -> QueryField: + return QueryField( + fieldspec=QueryFieldSpec.from_path(("Collectionobject", *path)), + op_num=8, + value=None, + negate=False, + display=True, + format_name=None, + sort_type=0, + ) + + def _commit_edit(self) -> Spdataset: + props = self.build_props( + [self._query_field(["catalognumber"]), self._query_field(["remarks"])], + "Collectionobject", + ) + headers, rows, packs, plan_json, visual_order = run_batch_edit_query(props) + rows_with_packs = [ + [*row, json.dumps({"batch_edit": pack})] for row, pack in zip(rows, packs) + ] + original_rows = regularize_rows(len(headers), rows_with_packs, skip_empty=False) + + dataset_id, _ = make_dataset( + user=self.specifyuser, + collection=self.collection, + name="rollback", + headers=headers, + regularized_rows=original_rows, + agent=self.agent, + json_upload_plan=plan_json, + visual_order=visual_order, + ) + dataset = Spdataset.objects.get(id=dataset_id) + + row_index = next( + index + for index, pack in enumerate(packs) + if pack["self"]["id"] == self.co.id + ) + remarks_column = next( + index + for index, header in enumerate(headers) + if header.lower().endswith("remarks") + ) + edited_rows = [list(row) for row in dataset.data] + edited_rows[row_index][remarks_column] = EDITED_REMARKS + dataset.data = edited_rows + dataset.save() + + do_upload_dataset( + self.collection, + self.agent.id, + dataset, + no_commit=False, + allow_partial=False, + ) + dataset.refresh_from_db() + return dataset + + def test_commit_applies_the_edit(self): + self._commit_edit() + self.co.refresh_from_db() + self.assertEqual(self.co.remarks, EDITED_REMARKS) + + def test_rollback_restores_the_original_values(self): + dataset = self._commit_edit() + rollback_batch_edit(dataset, self.collection, self.agent) + self.co.refresh_from_db() + self.assertEqual(self.co.remarks, ORIGINAL_REMARKS) + + def test_rollback_does_not_delete_edited_records(self): + dataset = self._commit_edit() + rollback_batch_edit(dataset, self.collection, self.agent) + self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists()) From ba1d99f0284e9f8fb59e552aa50f08e4f48bec67 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Thu, 24 Sep 2026 14:52:26 -0500 Subject: [PATCH 16/28] [fix]: fix for 58c75db failed tests --- .../upload/tests/test_batch_edit_rollback.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py index 1aac1ae38b2..3e0cb61045f 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py @@ -1,18 +1,13 @@ import json - -from specifyweb.backend.stored_queries.batch_edit import ( - make_dataset, - run_batch_edit_query, -) +from specifyweb.backend.stored_queries.batch_edit import make_dataset, run_batch_edit_query # type: ignore from specifyweb.backend.stored_queries.queryfield import QueryField from specifyweb.backend.stored_queries.queryfieldspec import QueryFieldSpec from specifyweb.backend.stored_queries.tests.test_batch_edit import props_builder from specifyweb.backend.stored_queries.tests.tests import SQLAlchemySetup from specifyweb.backend.workbench.models import Spdataset -from specifyweb.backend.workbench.tasks import unupload from specifyweb.backend.workbench.upload.tests.base import UploadTestsBase from specifyweb.backend.workbench.upload.upload import ( - do_upload_dataset, + do_upload_dataset, rollback_batch_edit, ) from specifyweb.backend.workbench.views import regularize_rows @@ -20,8 +15,8 @@ """ Creating a batch edit data set also creates a backup through the make_dataset -function. Rolling back (rollback_batch_edit) re-uploads the backedup values over -the committed records, then unuploads the data set +function. Rolling back (rollback_batch_edit) re-uploads the backed up values over +the committed records, calling upon unupload to roll back the data set """ ORIGINAL_REMARKS = "Remarks before batch edit" @@ -113,4 +108,4 @@ def test_rollback_restores_the_original_values(self): def test_rollback_does_not_delete_edited_records(self): dataset = self._commit_edit() rollback_batch_edit(dataset, self.collection, self.agent) - self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists()) + self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists()) \ No newline at end of file From b142f1857d0012c87bf082f8798b2b3f31d33093 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 09:31:05 -0500 Subject: [PATCH 17/28] [test]: verify you can roll back --- .../upload/tests/test_batch_edit_rollback.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py index 3e0cb61045f..392d16316c1 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_rollback.py @@ -12,6 +12,7 @@ ) from specifyweb.backend.workbench.views import regularize_rows from specifyweb.specify.models import Collectionobject +from specifyweb.backend.workbench.tasks import unupload """ Creating a batch edit data set also creates a backup through the make_dataset @@ -108,4 +109,17 @@ def test_rollback_restores_the_original_values(self): def test_rollback_does_not_delete_edited_records(self): dataset = self._commit_edit() rollback_batch_edit(dataset, self.collection, self.agent) - self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists()) \ No newline at end of file + self.assertTrue(Collectionobject.objects.filter(id=self.co.id).exists()) + + def test_rollback_makes_the_data_set_rolled_back(self): + dataset = self._commit_edit() + dataset.uploaderstatus = {"operation": "unuploading", "taskid": None} + dataset.save() + unupload(self.collection.id, dataset.id, self.agent.id) + dataset.refresh_from_db() + self.assertTrue(dataset.rolledback) + self.assertIsNone(dataset.uploaderstatus) + self.assertTrue(dataset.get_dataset_as_dict()["rolledback"]) + self.assertTrue(dataset.get_dataset_as_dict()["isupdate"]) + self.co.refresh_from_db() + self.assertEqual(self.co.remarks, ORIGINAL_REMARKS) \ No newline at end of file From 0ed0dffeca1e26999a0cb0193c6f67dfd4bdf059 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 09:56:32 -0500 Subject: [PATCH 18/28] [test]: verify after upload and roll back that the data set is read only --- .../WorkBench/__tests__/DataSetMeta.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx new file mode 100644 index 00000000000..a02f94983a9 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/WorkBench/__tests__/DataSetMeta.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { batchEditText } from '../../../localization/batchEdit'; +import { mount } from '../../../tests/reactUtils'; +import { localized } from '../../../utils/types'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import type { Dataset } from '../../WbPlanView/Wrapped'; +import { DataSetName } from '../DataSetMeta'; + +const buildDataset = ({ + isupdate, + rolledback, +}: { + readonly isupdate: boolean; + readonly rolledback: boolean; +}): Dataset => ({ + id: 1, + name: localized('Data Set'), + timestampcreated: '', + timestampmodified: '', + createdbyagent: '/api/specify/agent/1/', + importedfilename: '', + modifiedbyagent: null, + remarks: '', + uploadresult: null, + uploaderstatus: null, + columns: ['Catalog Number', 'Remarks'], + rowresults: null, + rows: [['100', 'A remark']], + uploadplan: null, + visualorder: null, + isupdate, + rolledback, + usesattachments: false, + attachments: null, +}); + +const render = (dataset: Dataset) => + mount( + + + + ); + +describe('DataSetName', () => { + test('warns that a rolled back batch edit data set cannot be edited', () => { + const { getByText } = render( + buildDataset({ isupdate: true, rolledback: true }) + ); + expect( + getByText(batchEditText.cannotEditAfterRollback()) + ).toBeInTheDocument(); + }); + + test('does not warn before the batch edit data set is rolled back', () => { + const { queryByText } = render( + buildDataset({ isupdate: true, rolledback: false }) + ); + expect( + queryByText(batchEditText.cannotEditAfterRollback()) + ).not.toBeInTheDocument(); + }); + + test('does not warn for a rolled back workbench data set', () => { + // Workbench data sets can still be edited and uploaded after a rollback. + const { queryByText } = render( + buildDataset({ isupdate: false, rolledback: true }) + ); + expect( + queryByText(batchEditText.cannotEditAfterRollback()) + ).not.toBeInTheDocument(); + }); +}); From b19a0294bd0935134fb9ac5b0ebd84030c81eb80 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 10:51:40 -0500 Subject: [PATCH 19/28] [test]: enable relationships in user preferences --- .../__tests__/BatchEditFromQuery.test.tsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx index b21d15d2ecd..1e6d215b736 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx @@ -6,10 +6,12 @@ import { queryText } from '../../../localization/query'; import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; import { mount } from '../../../tests/reactUtils'; +import * as ajaxModule from '../../../utils/ajax'; import type { RA } from '../../../utils/types'; import { LoadingContext } from '../../Core/Contexts'; import { UnloadProtectsContext } from '../../Router/UnloadProtect'; import { tables } from '../../DataModel/tables'; +import { userPreferences } from '../../Preferences/userPreferences'; import type { QueryField } from '../../QueryBuilder/helpers'; import type { MappingPath } from '../../WbPlanView/Mapper'; import { BatchEditFromQuery } from '../index'; @@ -158,3 +160,46 @@ describe('the unsaved query guard', () => { }); }); }); + +// Enable raltionships in user preferences. +function mockEnableRelationships(enabled: boolean): void { + const realGet = userPreferences.get.bind(userPreferences); + jest.spyOn(userPreferences, 'get').mockImplementation((( + category: string, + subcategory: string, + item: string + ) => + category === 'batchEdit' && + subcategory === 'editor' && + item === 'enableRelationships' + ? enabled + : realGet(category as never, subcategory as never, item as never)) as never); +} + +describe('the enable relationships preference', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test.each([ + [true, false], + [false, true], + ])( + 'enableRelationships=%s asks the back end for omitRelationships=%s', + async (enableRelationships, omitRelationships) => { + mockEnableRelationships(enableRelationships); + const ajax = jest.spyOn(ajaxModule, 'ajax'); + const { getByRole, findByText, user } = render(); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + expect(await findByText('Data set opened')).toBeInTheDocument(); + }); + expect(ajax).toHaveBeenCalledWith( + '/stored_query/batch_edit/', + expect.objectContaining({ + body: expect.objectContaining({ omitrelationships: omitRelationships }), + }) + ); + } + ); +}); From 56ef41989a94c910d75c53f713ace75a6cc5681a Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 11:16:06 -0500 Subject: [PATCH 20/28] [test]: ensure you can validate the data set (frontend) --- .../WbUtils/__tests__/datasetVariants.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts index 8a52e434f7f..aae95d8f326 100644 --- a/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts +++ b/specifyweb/frontend/js_src/lib/components/WbUtils/__tests__/datasetVariants.test.ts @@ -84,7 +84,7 @@ describe('batch edit rollback availability', () => { .defaultValue ).toBe(true); - // Unset preferences resolve to the declared defaults + // Unset preferences resolve to the defaults. expect( userPreferences.get('batchEdit', 'editor', 'enableRelationships') ).toBe(true); @@ -106,4 +106,25 @@ describe('batch edit rollback availability', () => { 'unupload' ); }); +}); + +describe('batch edit validation availability', () => { + test.each([true, false])( + 'follows the batch edit validate permission (%s)', + (permission) => { + mockedHasPermission.mockReturnValue(permission); + expect(datasetVariants.batchEdit.canValidate()).toBe(permission); + expect(mockedHasPermission).toHaveBeenCalledWith( + '/batch_edit/dataset', + 'validate' + ); + } + ); + + test('does not depend on the relationships preference', () => { + // Unlike rollback, validating is allowed when relationships are enabled. + setBatchEditPreferences(true, true); + mockedHasPermission.mockReturnValue(true); + expect(datasetVariants.batchEdit.canValidate()).toBe(true); + }); }); \ No newline at end of file From b76a13651ce4a84e855446ef275de15a6943d451 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 11:23:57 -0500 Subject: [PATCH 21/28] [test]: ensure you can validate the data set (backend) --- .../upload/tests/test_batch_edit_table.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py index db7a8c79504..d61e1773a7a 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py @@ -761,6 +761,91 @@ def test_batch_edit_attachment_dataset_commit_updates_original_record(self): self.assertEqual(Attachment.objects.count(), initial_attachment_count) self.enforce_in_log(self.co_1_attachment.id, "attachment", "UPDATE") + def _dataset_editing_a_preparation(self): + query_paths = [ + ["catalognumber"], + ["preparations", "countamt"], + ["preparations", "text1"], + ] + query_fields = [ + self.make_query(QueryFieldSpec.from_path(("Collectionobject", *path)), 0) + for path in query_paths + ] + props = self._build_props(query_fields, "Collectionobject") + + (headers, rows, packs, plan_json, visual_order) = run_batch_edit_query(props) + + mapped_rows = [ + [*row, json.dumps({"batch_edit": pack})] for (row, pack) in zip(rows, packs) + ] + regularized_rows = regularize_rows(len(headers), mapped_rows, skip_empty=False) + row_index = next( + index + for index, pack in enumerate(packs) + if pack["self"]["id"] == self.co_1.id + ) + + dataset_rows = [row[:] for row in regularized_rows] + dataset_rows[row_index][headers.index("Preparation text1")] = "Edited by batch edit" + + dataset_id, _ = make_dataset( + user=self.specifyuser, + collection=self.collection, + name="validate-batch-edit", + headers=headers, + regularized_rows=dataset_rows, + agent=self.agent, + json_upload_plan=plan_json, + visual_order=visual_order, + ) + return Spdataset.objects.get(id=dataset_id), row_index + + def test_validating_reports_changes_to_related_records(self): + dataset, row_index = self._dataset_editing_a_preparation() + + results = do_upload_dataset( + self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False + ) + + self.assertIsInstance( + results[row_index].toMany["preparations"][0].record_result, Updated + ) + + def test_validating_does_not_save_changes(self): + dataset, _ = self._dataset_editing_a_preparation() + + do_upload_dataset( + self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False + ) + + self.co_1_prep_1.refresh_from_db() + self.assertEqual(self.co_1_prep_1.text1, "Value for preparation") + + def test_validating_does_not_mark_the_data_set_uploaded(self): + dataset, _ = self._dataset_editing_a_preparation() + + do_upload_dataset( + self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False + ) + + dataset.refresh_from_db() + self.assertIsNotNone(dataset.rowresults) + self.assertIsNone(dataset.uploadresult) + self.assertFalse(dataset.was_uploaded()) + + def test_a_validated_data_set_can_be_committed(self): + dataset, _ = self._dataset_editing_a_preparation() + + do_upload_dataset( + self.collection, self.agent.id, dataset, no_commit=True, allow_partial=False + ) + do_upload_dataset( + self.collection, self.agent.id, dataset, no_commit=False, allow_partial=False + ) + + self.co_1_prep_1.refresh_from_db() + self.assertEqual(self.co_1_prep_1.text1, "Edited by batch edit") + def enforce_in_log( self, record_id, From 67e0078f128ad51728dcd184f69e5e77ec94a7ae Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 12:29:21 -0500 Subject: [PATCH 22/28] [fix]: fix for failing frontend test in b0ffbbd --- .../BatchEdit/__tests__/BatchEditFromQuery.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx index 1e6d215b736..c70eb30e142 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx @@ -71,9 +71,7 @@ function render({ }; return mount( + initialEntries={['/']}> From 31d6bc43710b6cd8fd1d544f2ca8cdeb3575cba0 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 12:52:10 -0500 Subject: [PATCH 23/28] [test]: make a tree query with a rank that is not the lowest (frontend) --- .../__tests__/missingRanksUtils.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts new file mode 100644 index 00000000000..3889d2b0eb4 --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/missingRanksUtils.test.ts @@ -0,0 +1,55 @@ +import { requireContext } from '../../../tests/helpers'; +import type { RA } from '../../../utils/types'; +import { tables } from '../../DataModel/tables'; +import { QueryFieldSpec } from '../../QueryBuilder/fieldSpec'; +import type { MappingPath } from '../../WbPlanView/Mapper'; +import { anyTreeRank, formatTreeRank } from '../../WbPlanView/mappingHelpers'; +import { findAllMissing } from '../missingRanksUtils'; + +requireContext(); + +const atRank = (rank: string, field = 'name'): MappingPath => [ + formatTreeRank(rank), + field, +]; + +const taxonQuery = (...paths: RA): RA => + paths.map((path) => QueryFieldSpec.fromPath('Taxon', path)); + +const missing = (...ranks: RA): RA => + ranks.map((rank) => `${rank} - ${tables.Taxon.strictGetField('name').label}`); + +describe('findAllMissing', () => { + test('a query at a rank that is not the lowest is missing every rank below it', () => { + expect(findAllMissing(taxonQuery(atRank('Genus')))).toEqual({ + Taxon: { Taxonomy: missing('Subgenus', 'Species', 'Subspecies') }, + }); + }); + + test('a query at the lowest rank is missing nothing', () => { + expect(findAllMissing(taxonQuery(atRank('Subspecies')))).toEqual({}); + }); + + test('ranks between two queried ranks are still required', () => { + expect( + findAllMissing(taxonQuery(atRank('Genus'), atRank('Species'))) + ).toEqual({ + Taxon: { Taxonomy: missing('Subgenus', 'Subspecies') }, + }); + }); + + test('a rank queried without its name counts as missing', () => { + expect(findAllMissing(taxonQuery(atRank('Genus', 'author')))).toEqual({ + Taxon: { Taxonomy: missing('Genus', 'Subgenus', 'Species', 'Subspecies') }, + }); + }); + + test('ignores fields that are not tied to a specific rank', () => { + expect( + findAllMissing([ + QueryFieldSpec.fromPath('CollectionObject', ['catalogNumber']), + ...taxonQuery([formatTreeRank(anyTreeRank), 'name']), + ]) + ).toEqual({}); + }); +}); From e18b7be881a1694343cad2fe5d1acdf96b6def3c Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 13:11:39 -0500 Subject: [PATCH 24/28] [test]: verify there is a missing rank dialog --- .../__tests__/BatchEditFromQuery.test.tsx | 68 ++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx index c70eb30e142..82d257b381f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/BatchEditFromQuery.test.tsx @@ -1,7 +1,9 @@ +import { within } from '@testing-library/react'; import React from 'react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { batchEditText } from '../../../localization/batchEdit'; import { commonText } from '../../../localization/common'; +import { interactionsText } from '../../../localization/interactions'; import { queryText } from '../../../localization/query'; import { overrideAjax } from '../../../tests/ajax'; import { requireContext } from '../../../tests/helpers'; @@ -14,6 +16,7 @@ import { tables } from '../../DataModel/tables'; import { userPreferences } from '../../Preferences/userPreferences'; import type { QueryField } from '../../QueryBuilder/helpers'; import type { MappingPath } from '../../WbPlanView/Mapper'; +import { formatTreeRank } from '../../WbPlanView/mappingHelpers'; import { BatchEditFromQuery } from '../index'; requireContext(); @@ -59,7 +62,7 @@ function render({ needsSaved = false, }: { readonly saveRequired?: boolean; - readonly baseTableName?: 'Collection' | 'CollectionObject'; + readonly baseTableName?: 'Collection' | 'CollectionObject' | 'Taxon'; readonly fields?: RA; readonly contextName?: string; readonly needsSaved?: boolean; @@ -201,3 +204,66 @@ describe('the enable relationships preference', () => { } ); }); + +const renderTaxonQuery = (rank: string) => + render({ + baseTableName: 'Taxon', + contextName: 'Taxon', + fields: [queryField([formatTreeRank(rank), 'name'])], + }); + +const missingRank = (rank: string): string => + `${rank} - ${tables.Taxon.strictGetField('name').label}`; + +describe('the missing rank dialog', () => { + test('appears when ranks are missing', async () => { + const { getByRole, findByRole, queryByText, user } = renderTaxonQuery('Genus'); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + const dialog = await findByRole('dialog'); + expect(dialog).toHaveTextContent(batchEditText.missingRanksInQuery()); + expect( + within(dialog) + .getAllByRole('listitem') + .map((item) => item.textContent) + ).toEqual(['Subgenus', 'Species', 'Subspecies'].map(missingRank)); + expect(queryByText('Data set opened')).toBeNull(); + }); + }); + + test('does not appear when the query already reaches the lowest rank', async () => { + const { getByRole, findByText, queryByRole, user } = + renderTaxonQuery('Subspecies'); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + expect(await findByText('Data set opened')).toBeInTheDocument(); + expect(queryByRole('dialog')).toBeNull(); + }); + }); + + test('continuing creates the data set', async () => { + const { getByRole, findByRole, findByText, user } = renderTaxonQuery('Genus'); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + const dialog = await findByRole('dialog'); + await user.click( + within(dialog).getByRole('button', { name: interactionsText.continue() }) + ); + expect(await findByText('Data set opened')).toBeInTheDocument(); + }); + }); + + test('closing it does not create the data set', async () => { + const { getByRole, findByRole, queryByRole, queryByText, user } = + renderTaxonQuery('Genus'); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + const dialog = await findByRole('dialog'); + await user.click( + within(dialog).getByRole('button', { name: commonText.close() }) + ); + expect(queryByRole('dialog')).toBeNull(); + expect(queryByText('Data set opened')).toBeNull(); + }); + }); +}); From 63dccc92c8297c5dc63003287feda17b3e086be4 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 13:39:28 -0500 Subject: [PATCH 25/28] [test]: with a rank that is in multiple trees you can select which tree(s) to add --- .../BatchEdit/__tests__/MissingRanks.test.tsx | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx new file mode 100644 index 00000000000..e63abdcc37f --- /dev/null +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx @@ -0,0 +1,173 @@ +import { within } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { batchEditText } from '../../../localization/batchEdit'; +import { interactionsText } from '../../../localization/interactions'; +import { overrideAjax } from '../../../tests/ajax'; +import specifyTrees from '../../../tests/ajax/static/trees/specify_trees.json'; +import { requireContext } from '../../../tests/helpers'; +import { mount } from '../../../tests/reactUtils'; +import * as ajaxModule from '../../../utils/ajax'; +import type { RA } from '../../../utils/types'; +import { LoadingContext } from '../../Core/Contexts'; +import { tables } from '../../DataModel/tables'; +import { UnloadProtectsContext } from '../../Router/UnloadProtect'; +import { formatTreeRank } from '../../WbPlanView/mappingHelpers'; +import { BatchEditFromQuery } from '../index'; + +const [taxonomy] = specifyTrees.Taxon; +const plantTreeUri = '/api/specify/taxontreedef/2/'; +const plantTaxonomy = { + definition: { + ...taxonomy.definition, + id: 2, + name: 'Plant Taxonomy', + resource_uri: plantTreeUri, + }, + ranks: taxonomy.ranks + .filter(({ name }) => + ['Taxonomy Root', 'Kingdom', 'Genus', 'Species'].includes(name) + ) + .map((rank) => ({ + ...rank, + id: rank.id + 100, + treedef: plantTreeUri, + resource_uri: `/api/specify/taxontreedefitem/${rank.id + 100}/`, + })), +}; + +overrideAjax('/trees/specify_trees/', { + ...specifyTrees, + Taxon: [taxonomy, plantTaxonomy], +}); + +requireContext(); + +overrideAjax('/api/workbench/dataset/?isupdate=1', []); +overrideAjax('/stored_query/batch_edit/', { id: 7 }, { method: 'POST' }); + +async function withoutActWarnings( + callback: () => Promise +): Promise { + const consoleError = jest.spyOn(console, 'error').mockImplementation(); + try { + await callback(); + } finally { + consoleError.mockRestore(); + } +} + +function renderGenusQuery() { + const query = new tables.SpQuery.Resource({ + name: 'Test Query', + contextName: 'Taxon', + contextTableId: tables.Taxon.tableId, + }); + const handleLoading = (promise: Promise): void => { + void promise; + }; + return mount( + + + + + + } + path="/" + /> + Data set opened

} + path="/specify/workbench/:id" + /> +
+
+
+
+ ); +} + +// Open the dialog, click each named tree's checkbox in turn, then continue +async function continueWithTrees(...trees: RA) { + const ajax = jest.spyOn(ajaxModule, 'ajax'); + const { getByRole, findByRole, findByText, user } = renderGenusQuery(); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + const dialog = await findByRole('dialog'); + for (const tree of trees) + await user.click( + within(dialog).getByRole('checkbox', { name: `${tree}:` }) + ); + await user.click( + within(dialog).getByRole('button', { name: interactionsText.continue() }) + ); + await findByText('Data set opened'); + }); + return ajax; +} + +const expectTreeFilter = ( + ajax: jest.SpyInstance, + treedefsfilter: unknown +): void => + expect(ajax).toHaveBeenCalledWith( + '/stored_query/batch_edit/', + expect.objectContaining({ + body: expect.objectContaining({ treedefsfilter }), + }) + ); + +describe('picking trees in the missing rank dialog', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('offers a checkbox for each tree the rank is in', async () => { + const { getByRole, findByRole, user } = renderGenusQuery(); + await withoutActWarnings(async () => { + await user.click(getByRole('button', { name: batchEditText.batchEdit() })); + const dialog = await findByRole('dialog'); + expect(dialog).toHaveTextContent(batchEditText.pickTreesToFilter()); + expect( + within(dialog).getByRole('checkbox', { name: 'Taxonomy:' }) + ).toBeInTheDocument(); + expect( + within(dialog).getByRole('checkbox', { name: 'Plant Taxonomy:' }) + ).toBeInTheDocument(); + }); + }); + + test('sends only the tree that was picked', async () => { + const ajax = await continueWithTrees('Plant Taxonomy'); + expectTreeFilter(ajax, { taxon: [2] }); + }); + + test('sends every tree that was picked', async () => { + const ajax = await continueWithTrees('Taxonomy', 'Plant Taxonomy'); + expectTreeFilter(ajax, { taxon: [1, 2] }); + }); + + test('clicking a picked tree again removes it', async () => { + const ajax = await continueWithTrees( + 'Plant Taxonomy', + 'Taxonomy', + 'Plant Taxonomy' + ); + expectTreeFilter(ajax, { taxon: [1] }); + }); +}); From 8a5d01e63aaed5d00e581c3b31aad2112ae0de89 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 13:41:27 -0500 Subject: [PATCH 26/28] [test]: verify if no trees are selected in the dialog that all trees are added (frontend) --- .../lib/components/BatchEdit/__tests__/MissingRanks.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx index e63abdcc37f..2fbbf353b9f 100644 --- a/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx +++ b/specifyweb/frontend/js_src/lib/components/BatchEdit/__tests__/MissingRanks.test.tsx @@ -170,4 +170,9 @@ describe('picking trees in the missing rank dialog', () => { ); expectTreeFilter(ajax, { taxon: [1] }); }); + + test('sends no filter when no tree is picked', async () => { + const ajax = await continueWithTrees(); + expectTreeFilter(ajax, {}); + }); }); From 54125adb00711e17c1807da5ffc664ff8ba1add6 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 14:14:06 -0500 Subject: [PATCH 27/28] [test]: verify ranks of selected trees are added correctly --- .../upload/tests/test_batch_edit_table.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py index 48c5f90d148..10e257bd8b0 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py @@ -1104,6 +1104,96 @@ def test_a_validated_data_set_can_be_committed(self): self.co_1_prep_1.refresh_from_db() self.assertEqual(self.co_1_prep_1.text1, "Edited by batch edit") + + def _make_plant_tree(self): + plant_tree = get_table("Taxontreedef").objects.create( + name="Plant ttd", discipline=self.discipline + ) + plant_tree.treedefitems.create(name="Taxonomy Root", rankid=0) + plant_tree.treedefitems.create(name="Kingdom", rankid=10) + plant_tree.treedefitems.create(name="Genus", rankid=180) + plant_tree.treedefitems.create(name="Species", rankid=220) + plant_tree.treedefitems.create(name="Variety", rankid=240) + return plant_tree + + def _tree_ranks_for_genus_query(self, treedefsfilter): + query_fields = fields_from_json( + [ + { + "tablelist": "1", + "stringid": "1.collectionobject.catalogNumber", + "fieldname": "catalogNumber", + "isrelfld": False, + "sorttype": 0, + "position": 0, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + { + "tablelist": "1,9-determinations,4", + "stringid": "1,9-determinations,4.taxon.Genus", + "fieldname": "Genus", + "isrelfld": False, + "sorttype": 0, + "position": 1, + "isdisplay": True, + "operstart": 8, + "startvalue": "", + "isnot": False, + }, + ] + ) + get_table("Determination").objects.create( + collectionobject=self.co_1, remarks="A determination" + ) + props = self._build_props(query_fields, "Collectionobject") + props["treedefsfilter"] = treedefsfilter + + (headers, rows, packs, plan_json, visual_order) = run_batch_edit_query(props) + + found = set() + + def collect(node): + if isinstance(node, dict): + for rank_key in node.get("treeRecord", {}).get("ranks", {}): + tree_name, rank_name = rank_key.split(RANK_KEY_DELIMITER)[:2] + found.add((tree_name, rank_name)) + for value in node.values(): + collect(value) + elif isinstance(node, list): + for value in node: + collect(value) + + collect(plan_json) + return found + + def _default_tree_ranks(self): + return { + (self.taxontreedef.name, rank) + for rank in ["Genus", "Subgenus", "Species", "Subspecies"] + } + + def _plant_tree_ranks(self): + return {("Plant ttd", rank) for rank in ["Genus", "Species", "Variety"]} + + def test_only_the_selected_trees_ranks_are_added(self): + plant_tree = self._make_plant_tree() + + ranks = self._tree_ranks_for_genus_query({"taxon": [plant_tree.id]}) + + self.assertEqual(ranks, self._plant_tree_ranks()) + + def test_every_selected_trees_ranks_are_added(self): + plant_tree = self._make_plant_tree() + + ranks = self._tree_ranks_for_genus_query( + {"taxon": [self.taxontreedef.id, plant_tree.id]} + ) + + self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks()) + def enforce_in_log( self, record_id, From db11ea4398a126dceb7c3c6838d717144a40dbd3 Mon Sep 17 00:00:00 2001 From: kwhuber Date: Fri, 25 Sep 2026 14:17:55 -0500 Subject: [PATCH 28/28] [test]: verify if no trees are selected in the dialog that all trees are added (backend) --- .../workbench/upload/tests/test_batch_edit_table.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py index 10e257bd8b0..282beec9085 100644 --- a/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py +++ b/specifyweb/backend/workbench/upload/tests/test_batch_edit_table.py @@ -1194,6 +1194,13 @@ def test_every_selected_trees_ranks_are_added(self): self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks()) + def test_no_trees_selected_adds_every_tree(self): + self._make_plant_tree() + + ranks = self._tree_ranks_for_genus_query({}) + + self.assertEqual(ranks, self._default_tree_ranks() | self._plant_tree_ranks()) + def enforce_in_log( self, record_id,