From 65540c6a85d1dc90a82d3196a466eed39171002f Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:17:55 -0400 Subject: [PATCH 01/10] feat: add encodeDOIPath utility for safe DOI URL encoding --- src/utils/common/encodeDOI.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/utils/common/encodeDOI.ts diff --git a/src/utils/common/encodeDOI.ts b/src/utils/common/encodeDOI.ts new file mode 100644 index 000000000..daf3f5143 --- /dev/null +++ b/src/utils/common/encodeDOI.ts @@ -0,0 +1,10 @@ +/** + * Encodes a DOI value for safe insertion into a URL path. + * + * Uses encodeURIComponent to encode all URL-unsafe characters (#, ?, <, >, [, ], + * spaces, etc.) then restores '/' which is a legitimate path separator in both + * doi.org URLs and ADS gateway URLs. + */ +export function encodeDOIPath(doi: string): string { + return encodeURIComponent(doi).replace(/%2F/gi, '/'); +} From 0fe1fd9bef79cbb908e50fd777aaf41a0bc7c4c3 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:18:00 -0400 Subject: [PATCH 02/10] test: add unit tests for encodeDOIPath --- src/utils/common/__tests__/encodeDOI.test.ts | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/utils/common/__tests__/encodeDOI.test.ts diff --git a/src/utils/common/__tests__/encodeDOI.test.ts b/src/utils/common/__tests__/encodeDOI.test.ts new file mode 100644 index 000000000..88785fd53 --- /dev/null +++ b/src/utils/common/__tests__/encodeDOI.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { encodeDOIPath } from '../encodeDOI'; + +describe('encodeDOIPath', () => { + it('encodes # as %23', () => { + expect(encodeDOIPath('10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#')).toBe( + '10.1002/1521-3994(199908)320%3A4/5%3C163%3A%3AAID-ASNA163%3E3.0.CO%3B2-%23', + ); + }); + + it('encodes < and > as %3C and %3E', () => { + expect(encodeDOIPath('10.1000/foobaz')).toBe('10.1000/foo%3Cbar%3Ebaz'); + }); + + it('encodes ? as %3F', () => { + expect(encodeDOIPath('10.1000/foo?bar')).toBe('10.1000/foo%3Fbar'); + }); + + it('encodes space as %20', () => { + expect(encodeDOIPath('10.1000/foo bar')).toBe('10.1000/foo%20bar'); + }); + + it('preserves / as a path separator', () => { + expect(encodeDOIPath('10.48550/arXiv.2507.19320')).toBe('10.48550/arXiv.2507.19320'); + }); + + it('does not double-encode already-encoded sequences', () => { + // % itself gets encoded to %25, preventing double-encoding + expect(encodeDOIPath('10.1000/foo%23bar')).toBe('10.1000/foo%2523bar'); + }); + + it('leaves a plain DOI unchanged', () => { + expect(encodeDOIPath('10.1086/345794')).toBe('10.1086/345794'); + }); +}); From 6fe484a664ac6608d5b4e90e7a7b5f1d220549e8 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:27:18 -0400 Subject: [PATCH 03/10] fix: encode special characters in DOI identifier for gateway URLs --- .../__tests__/createUrlByType.test.ts | 33 +++++++++++++++++++ .../AbstractSources/linkGenerator.ts | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/components/AbstractSources/__tests__/createUrlByType.test.ts diff --git a/src/components/AbstractSources/__tests__/createUrlByType.test.ts b/src/components/AbstractSources/__tests__/createUrlByType.test.ts new file mode 100644 index 000000000..e9fc7aaa2 --- /dev/null +++ b/src/components/AbstractSources/__tests__/createUrlByType.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { createUrlByType } from '@/components/AbstractSources/linkGenerator'; + +describe('createUrlByType', () => { + it('encodes # in a DOI identifier', () => { + const url = createUrlByType( + '1999AN....320..163M', + 'doi', + '10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#', + ); + expect(url).not.toContain('#'); + expect(url).toContain('%23'); + }); + + it('encodes < and > in a DOI identifier', () => { + const url = createUrlByType('test', 'doi', '10.1000/foo'); + expect(url).toContain('%3C'); + expect(url).toContain('%3E'); + expect(url).not.toContain('<'); + expect(url).not.toContain('>'); + }); + + it('preserves / in DOI identifiers', () => { + const url = createUrlByType('test', 'doi', '10.48550/arXiv.2507.19320'); + expect(url).toContain('10.48550/arXiv.2507.19320'); + }); + + it('returns empty string for non-string arguments', () => { + expect(createUrlByType(null as unknown as string, 'doi', '10.1000/x')).toBe(''); + expect(createUrlByType('bib', null as unknown as string, '10.1000/x')).toBe(''); + expect(createUrlByType('bib', 'doi', null as unknown as string)).toBe(''); + }); +}); diff --git a/src/components/AbstractSources/linkGenerator.ts b/src/components/AbstractSources/linkGenerator.ts index 994831798..a5341a733 100644 --- a/src/components/AbstractSources/linkGenerator.ts +++ b/src/components/AbstractSources/linkGenerator.ts @@ -4,6 +4,7 @@ import { getOpenUrl } from './openUrlGenerator'; import { isNilOrEmpty, isNonEmptyString } from 'ramda-adjunct'; import { IDataProductSource, IFullTextSource, ProcessLinkDataReturns } from '@/components/AbstractSources/types'; import { Esources, IDocsEntity } from '@/api/search/types'; +import { encodeDOIPath } from '@/utils/common/encodeDOI'; /** * Create the resolver url @@ -139,7 +140,7 @@ export const processLinkData = (doc: IDocsEntity, linkServer?: string): ProcessL */ export const createUrlByType = function (bibcode: string, type: string, identifier: string): string { if (typeof bibcode === 'string' && typeof type === 'string' && typeof identifier === 'string') { - return `${GATEWAY_BASE_URL + bibcode}/${type}:${identifier}`; + return `${GATEWAY_BASE_URL + bibcode}/${type}:${encodeDOIPath(identifier)}`; } return ''; }; From 7c01b33de6554ed58bd38758efb102cd6d6e8c3c Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:31:58 -0400 Subject: [PATCH 04/10] fix: encode special characters in DOI sameAs URLs in JSON-LD --- .../json-ld-abstract/__tests__/identifier.test.ts | 12 ++++++++++++ .../Metatags/json-ld-abstract/identifiers.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/Metatags/json-ld-abstract/__tests__/identifier.test.ts b/src/components/Metatags/json-ld-abstract/__tests__/identifier.test.ts index 330abc8b5..75bb589d3 100644 --- a/src/components/Metatags/json-ld-abstract/__tests__/identifier.test.ts +++ b/src/components/Metatags/json-ld-abstract/__tests__/identifier.test.ts @@ -73,4 +73,16 @@ describe('collectIdentifiersFromArray', () => { expect(sa.has('https://hdl.handle.net/1234/abc')).toBe(true); expect(sa.size).toBe(3); }); + + it('encodes special characters in DOI sameAs URL', () => { + const { sameAs } = collectIdentifiersFromArray({ + identifier: ['10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#'], + }); + const doiLink = sameAs.find((u) => u.startsWith('https://doi.org/')); + expect(doiLink).toBeDefined(); + expect(doiLink).not.toContain('#'); + expect(doiLink).toContain('%23'); + expect(doiLink).not.toContain('<'); + expect(doiLink).toContain('%3C'); + }); }); diff --git a/src/components/Metatags/json-ld-abstract/identifiers.ts b/src/components/Metatags/json-ld-abstract/identifiers.ts index 08fb41d55..6dd6870b1 100644 --- a/src/components/Metatags/json-ld-abstract/identifiers.ts +++ b/src/components/Metatags/json-ld-abstract/identifiers.ts @@ -1,4 +1,5 @@ import type { PropertyValue } from 'schema-dts'; +import { encodeDOIPath } from '@/utils/common/encodeDOI'; /** * Minimal structure containing only identifiers we parse. @@ -68,7 +69,7 @@ function buildSameAs(pvs: PropertyValue[]) { const v = String(value); switch (propertyID) { case 'DOI': - out.add(`https://doi.org/${v}`); + out.add(`https://doi.org/${encodeDOIPath(v)}`); break; case 'arXiv': out.add(`https://arxiv.org/abs/${v}`); From b8f6fe2d6f6f38c037e173388536da42a32fe03b Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:56:12 -0400 Subject: [PATCH 05/10] fix: encode DOI values in OpenURL query parameters --- .../__tests__/openUrlGenerator.test.ts | 13 ++++++++++ .../AbstractSources/openUrlGenerator.ts | 24 +++++++++---------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/components/AbstractSources/__tests__/openUrlGenerator.test.ts b/src/components/AbstractSources/__tests__/openUrlGenerator.test.ts index 7b3521796..50ac92502 100644 --- a/src/components/AbstractSources/__tests__/openUrlGenerator.test.ts +++ b/src/components/AbstractSources/__tests__/openUrlGenerator.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import { processLinkData } from '@/components/AbstractSources/linkGenerator'; +import { getOpenUrl } from '@/components/AbstractSources/openUrlGenerator'; test('processLinkData produces correct output', () => { expect( @@ -151,3 +152,15 @@ test('processLinkData can handle empty input', () => { ), ).toEqual(defaultReturn); }); + +test('encodes # in DOI when building OpenURL', () => { + const url = getOpenUrl({ + metadata: { + doi: ['10.1002/1521-3994(199908)320:4/5<163::AID-ASNA163>3.0.CO;2-#'], + bibcode: '1999AN....320..163M', + }, + linkServer: 'https://example.com/openurl', + }); + expect(url).not.toContain('#'); + expect(url).toContain('%23'); +}); diff --git a/src/components/AbstractSources/openUrlGenerator.ts b/src/components/AbstractSources/openUrlGenerator.ts index 276b2f5e5..ed2bd8505 100644 --- a/src/components/AbstractSources/openUrlGenerator.ts +++ b/src/components/AbstractSources/openUrlGenerator.ts @@ -53,10 +53,10 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => { const parsed: Record = { ...STATIC_FIELDS, 'rft.spage': isArray(page) ? page[0].split('-')[0] : undefined, - id: isArray(doi) ? 'doi:' + doi[0] : undefined, + id: isArray(doi) ? 'doi:' + encodeURIComponent(doi[0]) : undefined, genre: genre, rft_id: [ - isArray(doi) ? 'info:doi/' + doi[0] : undefined, + isArray(doi) ? 'info:doi/' + encodeURIComponent(doi[0]) : undefined, isString(bibcode) ? 'info:bibcode/' + bibcode : undefined, ], 'rft.degree': degree, @@ -74,15 +74,15 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => { }; interface IContext extends Partial { - spage: typeof parsed['rft.spage']; - volume: typeof parsed['rft.volume']; - title: typeof parsed['rft.jtitle']; - atitle: typeof parsed['rft.atitle']; - aulast: typeof parsed['rft.aulast']; - aufirst: typeof parsed['rft.aufirst']; - date: typeof parsed['rft.date']; - isbn: typeof parsed['rft.isbn']; - issn: typeof parsed['rft.issn']; + spage: (typeof parsed)['rft.spage']; + volume: (typeof parsed)['rft.volume']; + title: (typeof parsed)['rft.jtitle']; + atitle: (typeof parsed)['rft.atitle']; + aulast: (typeof parsed)['rft.aulast']; + aufirst: (typeof parsed)['rft.aufirst']; + date: (typeof parsed)['rft.date']; + isbn: (typeof parsed)['rft.isbn']; + issn: (typeof parsed)['rft.issn']; } // add extra fields to context object @@ -116,5 +116,5 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => { return `${k}=${val}`; }); - return encodeURI(openUrl + fields.join('&')); + return openUrl + fields.join('&'); }; From 5f23783207f136f5fd8521205b8e47eef7252ecc Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:09:02 -0400 Subject: [PATCH 06/10] fix: encode all OpenURL query parameter values with encodeURIComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move percent-encoding from the DOI pre-encoding site to the map level so that all field values—including rft.atitle, rft.jtitle, rfr_id, etc.—are encoded exactly once, preventing raw special characters from breaking the query string. --- .../AbstractSources/__tests__/linkGenerator.test.ts | 4 ++-- src/components/AbstractSources/openUrlGenerator.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/AbstractSources/__tests__/linkGenerator.test.ts b/src/components/AbstractSources/__tests__/linkGenerator.test.ts index b176aedf5..d550636d1 100644 --- a/src/components/AbstractSources/__tests__/linkGenerator.test.ts +++ b/src/components/AbstractSources/__tests__/linkGenerator.test.ts @@ -108,7 +108,7 @@ describe('processLinkData', () => { rawType: 'INSTITUTION', shortName: 'My Institution', type: 'INSTITUTION', - url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft.degree=false&rft.genre=article', + url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft.degree=false&rft.genre=article', }, ], dataProducts: [], @@ -163,7 +163,7 @@ describe('processLinkData', () => { rawType: 'INSTITUTION', shortName: 'My Institution', type: 'INSTITUTION', - url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info:ofi/fmt:kev:mtx:article&rfr_id=info:sid/ADS&sid=ADS&id=doi:foo&genre=article&rft_id=info:doi/foo&rft_id=info:bibcode/test&rft.genre=article', + url: 'http://my-link-server.com/?url_ver=Z39.88-2004&rft_val_fmt=info%3Aofi%2Ffmt%3Akev%3Amtx%3Aarticle&rfr_id=info%3Asid%2FADS&sid=ADS&id=doi%3Afoo&genre=article&rft_id=info%3Adoi%2Ffoo&rft_id=info%3Abibcode%2Ftest&rft.genre=article', }, { description: 'Publisher PDF', diff --git a/src/components/AbstractSources/openUrlGenerator.ts b/src/components/AbstractSources/openUrlGenerator.ts index ed2bd8505..31214e3d1 100644 --- a/src/components/AbstractSources/openUrlGenerator.ts +++ b/src/components/AbstractSources/openUrlGenerator.ts @@ -53,10 +53,10 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => { const parsed: Record = { ...STATIC_FIELDS, 'rft.spage': isArray(page) ? page[0].split('-')[0] : undefined, - id: isArray(doi) ? 'doi:' + encodeURIComponent(doi[0]) : undefined, + id: isArray(doi) ? 'doi:' + doi[0] : undefined, genre: genre, rft_id: [ - isArray(doi) ? 'info:doi/' + encodeURIComponent(doi[0]) : undefined, + isArray(doi) ? 'info:doi/' + doi[0] : undefined, isString(bibcode) ? 'info:bibcode/' + bibcode : undefined, ], 'rft.degree': degree, @@ -110,10 +110,10 @@ export const getOpenUrl = (options: IGetOpenUrlOptions): string => { if (isArray(val)) { return val .filter((v) => v) - .map((v) => `${k}=${v}`) + .map((v) => `${k}=${encodeURIComponent(String(v))}`) .join('&'); } - return `${k}=${val}`; + return `${k}=${encodeURIComponent(String(val))}`; }); return openUrl + fields.join('&'); From bf13409451a2ddda150d0c193e994ce23e151cb1 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:09:17 -0400 Subject: [PATCH 07/10] test: add unit tests for pure utility and model functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover graphUtils transforms, visualization utils, ORCID model type guards, email notification keyword validator, and Nivo dark theme shape — all pure functions with no mocking required. --- src/api/orcid/models.test.ts | 131 +++++ .../EmailNotifications/Forms/Utils.test.tsx | 53 ++ .../Visualizations/utils/graphUtils.test.ts | 520 ++++++++++++++++++ .../Visualizations/utils/utils.test.ts | 120 ++++ src/lib/useNivoDarkTheme.test.ts | 45 ++ 5 files changed, 869 insertions(+) create mode 100644 src/api/orcid/models.test.ts create mode 100644 src/components/EmailNotifications/Forms/Utils.test.tsx create mode 100644 src/components/Visualizations/utils/graphUtils.test.ts create mode 100644 src/components/Visualizations/utils/utils.test.ts create mode 100644 src/lib/useNivoDarkTheme.test.ts diff --git a/src/api/orcid/models.test.ts b/src/api/orcid/models.test.ts new file mode 100644 index 000000000..9500cb18d --- /dev/null +++ b/src/api/orcid/models.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'vitest'; +import { isOrcidProfileEntry, isValidIOrcidUser, isValidOrcidId } from './models'; + +describe('isValidOrcidId', () => { + test('returns true for valid ORCID ids', () => { + expect(isValidOrcidId('0000-0002-1825-0097')).toBe(true); + expect(isValidOrcidId('1234-5678-9012-345X')).toBe(true); + }); + + test('returns false for malformed ORCID ids', () => { + expect(isValidOrcidId('0000-0002-1825-009')).toBe(false); + expect(isValidOrcidId('0000-0002-1825-00977')).toBe(false); + expect(isValidOrcidId('0000000218250097')).toBe(false); + expect(isValidOrcidId('0000-0002-1825-009x')).toBe(false); + expect(isValidOrcidId('abcd-0002-1825-0097')).toBe(false); + }); + + test('returns false for non-string values', () => { + expect(isValidOrcidId(null)).toBe(false); + expect(isValidOrcidId(undefined)).toBe(false); + expect(isValidOrcidId(1234)).toBe(false); + expect(isValidOrcidId({})).toBe(false); + }); +}); + +describe('isValidIOrcidUser', () => { + const validUser = { + access_token: 'token', + expires_in: 3600, + name: 'Ada Lovelace', + orcid: '0000-0002-1825-0097', + refresh_token: 'refresh', + scope: '/read-limited', + token_type: 'bearer', + }; + + test('returns true for a valid ORCID user object', () => { + expect(isValidIOrcidUser(validUser)).toBe(true); + }); + + test('returns true for boundary numeric values that still produce a valid date', () => { + expect(isValidIOrcidUser({ ...validUser, expires_in: 0 })).toBe(true); + expect(isValidIOrcidUser({ ...validUser, expires_in: -1 })).toBe(true); + }); + + test('returns false when required keys are missing', () => { + const { refresh_token, ...missingRefreshToken } = validUser; + + expect(isValidIOrcidUser(missingRefreshToken)).toBe(false); + }); + + test('returns false for non-object values', () => { + expect(isValidIOrcidUser(null)).toBe(false); + expect(isValidIOrcidUser(undefined)).toBe(false); + expect(isValidIOrcidUser('user')).toBe(false); + expect(isValidIOrcidUser([])).toBe(false); + }); + + test('returns false when any required property has the wrong type', () => { + expect(isValidIOrcidUser({ ...validUser, access_token: 123 })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, expires_in: '3600' })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, name: false })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, orcid: 12 })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, refresh_token: null })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, scope: {} })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, token_type: [] })).toBe(false); + }); + + test('returns false when expires_in produces an invalid date', () => { + expect(isValidIOrcidUser({ ...validUser, expires_in: NaN })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, expires_in: Infinity })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, expires_in: -Infinity })).toBe(false); + expect(isValidIOrcidUser({ ...validUser, expires_in: Number.MAX_SAFE_INTEGER })).toBe(false); + }); +}); + +describe('isOrcidProfileEntry', () => { + const validEntry = { + identifier: '2024MNRAS.123..456A', + status: 'verified', + title: 'A valid ORCID profile entry', + pubyear: '2024', + pubmonth: '06', + updated: '2024-06-02', + putcode: '12345', + source: ['ADS', 'ORCID'], + }; + + test('returns true for a fully valid profile entry', () => { + expect(isOrcidProfileEntry(validEntry)).toBe(true); + }); + + test('returns true for allowed status values and nullable fields', () => { + expect(isOrcidProfileEntry({ ...validEntry, status: 'not in ADS', pubyear: null, pubmonth: null })).toBe(true); + expect(isOrcidProfileEntry({ ...validEntry, status: 'pending', pubmonth: undefined })).toBe(true); + expect(isOrcidProfileEntry({ ...validEntry, status: 'rejected', putcode: 12345 })).toBe(true); + }); + + test('returns false for nil or empty entries', () => { + expect(isOrcidProfileEntry(null)).toBe(false); + expect(isOrcidProfileEntry(undefined)).toBe(false); + expect(isOrcidProfileEntry('')).toBe(false); + expect(isOrcidProfileEntry({})).toBe(false); + expect(isOrcidProfileEntry([])).toBe(false); + }); + + test('returns false when required scalar fields have the wrong type', () => { + expect(isOrcidProfileEntry({ ...validEntry, identifier: 123 })).toBe(false); + expect(isOrcidProfileEntry({ ...validEntry, title: 123 })).toBe(false); + expect(isOrcidProfileEntry({ ...validEntry, updated: 123 })).toBe(false); + expect(isOrcidProfileEntry({ ...validEntry, putcode: true })).toBe(false); + }); + + test('returns false for invalid status values', () => { + expect(isOrcidProfileEntry({ ...validEntry, status: 'unknown' })).toBe(false); + expect(isOrcidProfileEntry({ ...validEntry, status: null })).toBe(false); + }); + + test('returns false when source is not an array of strings', () => { + expect(isOrcidProfileEntry({ ...validEntry, source: 'ADS' })).toBe(false); + expect(isOrcidProfileEntry({ ...validEntry, source: ['ADS', 1] })).toBe(false); + }); + + test('returns false when pubmonth has the wrong type', () => { + expect(isOrcidProfileEntry({ ...validEntry, pubmonth: 6 })).toBe(false); + }); + + test('accepts a non-string pubyear when pubmonth is undefined because of the current implementation', () => { + expect(isOrcidProfileEntry({ ...validEntry, pubyear: 2024, pubmonth: undefined })).toBe(true); + }); +}); diff --git a/src/components/EmailNotifications/Forms/Utils.test.tsx b/src/components/EmailNotifications/Forms/Utils.test.tsx new file mode 100644 index 000000000..7399e1c67 --- /dev/null +++ b/src/components/EmailNotifications/Forms/Utils.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; + +import { isValidKeyword } from './Utils'; + +describe('isValidKeyword', () => { + test.each([ + ['', true], + ['plain keyword', true], + ['(', false], + [')', false], + ['[', false], + [']', false], + ['{', false], + ['}', false], + ])('returns %s for single or empty input: %s', (keyword, expected) => { + expect(isValidKeyword(keyword)).toBe(expected); + }); + + test.each([ + ['alpha (beta)', true], + ['alpha [beta]', true], + ['alpha (beta [gamma])', true], + ['alpha [beta (gamma)]', true], + ['([[]])', true], + ['([])[]', true], + ])('accepts balanced parentheses and brackets: %s', (keyword, expected) => { + expect(isValidKeyword(keyword)).toBe(expected); + }); + + test.each([ + ['alpha (beta', false], + ['alpha beta)', false], + ['alpha [beta', false], + ['alpha beta]', false], + ['([)]', false], + ['[(])', false], + ['(]', false], + ['[)', false], + ['([)', false], + ])('rejects unbalanced or mismatched parentheses and brackets: %s', (keyword, expected) => { + expect(isValidKeyword(keyword)).toBe(expected); + }); + + test.each([ + ['{}', false], + ['alpha {beta}', false], + ['({})', false], + ['[{}]', false], + ['alpha } beta', false], + ])('matches the current curly brace behavior in the implementation: %s', (keyword, expected) => { + expect(isValidKeyword(keyword)).toBe(expected); + }); +}); diff --git a/src/components/Visualizations/utils/graphUtils.test.ts b/src/components/Visualizations/utils/graphUtils.test.ts new file mode 100644 index 000000000..bf6be47c1 --- /dev/null +++ b/src/components/Visualizations/utils/graphUtils.test.ts @@ -0,0 +1,520 @@ +import { describe, expect, test } from 'vitest'; + +import { + BasicStatsKey, + CitationsHistogramKey, + CitationsStatsKey, + PapersHistogramKey, + ReadsHistogramKey, + TimeSeriesKey, + type CitationsHistogramType, + type PapersHistogramType, + type ReadsHistogramType, + type TimeSeriesType, +} from '@/api/metrics/types'; +import type { IFacetCountsFields, IDocsEntity, IBucket } from '@/api/search/types'; +import { + getCitationTableData, + getHIndexGraphData, + getIndicesTableData, + getPapersTableData, + getReadsTableData, + getResultsGraph, + getYearsGraph, + plotCitationsHist, + plotPapersHist, + plotReadsHist, + plotTimeSeriesGraph, +} from './graphUtils'; + +const createCitationsHistogram = (overrides: Partial = {}): CitationsHistogramType => ({ + [CitationsHistogramKey.RR]: { '2000': 3, '2001': 1 }, + [CitationsHistogramKey.RN]: { '2000': 4, '2001': 0 }, + [CitationsHistogramKey.NR]: { '2000': 0, '2001': 0 }, + [CitationsHistogramKey.NN]: { '2000': 2, '2001': 2 }, + [CitationsHistogramKey.RRN]: { '2000': 1.5, '2001': 0.5 }, + [CitationsHistogramKey.RNN]: { '2000': 0, '2001': 0 }, + [CitationsHistogramKey.NRN]: { '2000': 2.5, '2001': 0 }, + [CitationsHistogramKey.NNN]: { '2000': 0, '2001': 0 }, + ...overrides, +}); + +const createReadsHistogram = (overrides: Partial = {}): ReadsHistogramType => ({ + [ReadsHistogramKey.RR]: { '2000': 3, '2001': 0 }, + [ReadsHistogramKey.AR]: { '2000': 5, '2001': 7 }, + [ReadsHistogramKey.RRN]: { '2000': 1.5, '2001': 0 }, + [ReadsHistogramKey.ARN]: { '2000': 2.5, '2001': 3 }, + ...overrides, +}); + +const createPapersHistogram = (overrides: Partial = {}): PapersHistogramType => ({ + [PapersHistogramKey.RP]: { '2000': 2, '2001': 1 }, + [PapersHistogramKey.AP]: { '2000': 4, '2001': 3 }, + [PapersHistogramKey.RPN]: { '2000': 0.5, '2001': 0.25 }, + [PapersHistogramKey.APN]: { '2000': 1.5, '2001': 0.25 }, + ...overrides, +}); + +describe('plotCitationsHist', () => { + test('builds non-normalized chart data and skips zero-only series', () => { + const histogram = createCitationsHistogram(); + + expect(plotCitationsHist(false, histogram, false)).toEqual({ + data: [ + { + year: '2000', + 'Ref. citations to ref. papers': 3, + 'Ref. citations to non ref. papers': 4, + 'Non ref. citations to non ref. papers': 2, + }, + { + year: '2001', + 'Ref. citations to ref. papers': 1, + 'Ref. citations to non ref. papers': 0, + 'Non ref. citations to non ref. papers': 2, + }, + ], + keys: [ + 'Ref. citations to ref. papers', + 'Ref. citations to non ref. papers', + 'Non ref. citations to non ref. papers', + ], + indexBy: 'year', + }); + }); + + test('uses single-paper labels for normalized data', () => { + const histogram = createCitationsHistogram(); + + expect(plotCitationsHist(true, histogram, true)).toEqual({ + data: [ + { + year: '2000', + 'Citations from ref. papers': 1.5, + 'Citations from non ref. papers': 2.5, + }, + { + year: '2001', + 'Citations from ref. papers': 0.5, + 'Citations from non ref. papers': 0, + }, + ], + keys: ['Citations from ref. papers', 'Citations from non ref. papers'], + indexBy: 'year', + }); + }); + + test('preserves years from the first series and leaves missing values undefined', () => { + const histogram = createCitationsHistogram({ + [CitationsHistogramKey.RN]: { '2000': 4 }, + }); + + expect(plotCitationsHist(false, histogram, false)).toEqual({ + data: [ + { + year: '2000', + 'Ref. citations to ref. papers': 3, + 'Ref. citations to non ref. papers': 4, + 'Non ref. citations to non ref. papers': 2, + }, + { + year: '2001', + 'Ref. citations to ref. papers': 1, + 'Ref. citations to non ref. papers': undefined, + 'Non ref. citations to non ref. papers': 2, + }, + ], + keys: [ + 'Ref. citations to ref. papers', + 'Ref. citations to non ref. papers', + 'Non ref. citations to non ref. papers', + ], + indexBy: 'year', + }); + }); +}); + +describe('plotReadsHist and plotPapersHist', () => { + test.each([ + { + name: 'plotReadsHist non-normalized', + run: () => plotReadsHist(false, createReadsHistogram()), + expected: { + data: [ + { year: '2000', Refereed: 3, 'Non-refereed': 2 }, + { year: '2001', Refereed: 0, 'Non-refereed': 7 }, + ], + keys: ['Refereed', 'Non-refereed'], + indexBy: 'year', + }, + }, + { + name: 'plotPapersHist normalized', + run: () => plotPapersHist(true, createPapersHistogram()), + expected: { + data: [ + { year: '2000', Refereed: 0.5, 'Non-refereed': 1 }, + { year: '2001', Refereed: 0.25, 'Non-refereed': 0 }, + ], + keys: ['Refereed', 'Non-refereed'], + indexBy: 'year', + }, + }, + ])('$name', ({ run, expected }) => { + expect(run()).toEqual(expected); + }); + + test.each([ + { + name: 'plotReadsHist omits the non-refereed key when all derived values are zero', + run: () => + plotReadsHist( + false, + createReadsHistogram({ + [ReadsHistogramKey.RR]: { '2000': 2, '2001': 1 }, + [ReadsHistogramKey.AR]: { '2000': 2, '2001': 1 }, + }), + ), + expected: { + data: [ + { year: '2000', Refereed: 2 }, + { year: '2001', Refereed: 1 }, + ], + keys: ['Refereed'], + indexBy: 'year', + }, + }, + { + name: 'plotPapersHist keeps values from all publications when the refereed year is missing', + run: () => + plotPapersHist( + false, + createPapersHistogram({ + [PapersHistogramKey.RP]: { '2000': 2 }, + [PapersHistogramKey.AP]: { '2000': 4, '2001': 3 }, + }), + ), + expected: { + data: [{ year: '2000', Refereed: 2, 'Non-refereed': 2 }], + keys: ['Refereed', 'Non-refereed'], + indexBy: 'year', + }, + }, + ])('$name', ({ run, expected }) => { + expect(run()).toEqual(expected); + }); +}); + +describe('plotTimeSeriesGraph', () => { + test('builds series in implementation order and divides read10 values by 10', () => { + const series: TimeSeriesType = { + [TimeSeriesKey.H]: { '2000': 3, '2001': 4 }, + [TimeSeriesKey.G]: { '2000': 5 }, + [TimeSeriesKey.READ10]: { '2000': 40, '2001': 15 }, + }; + + expect(plotTimeSeriesGraph(series)).toEqual({ + data: [ + { + id: 'h-index', + data: [ + { x: '2000', y: 3 }, + { x: '2001', y: 4 }, + ], + }, + { + id: 'g-index', + data: [{ x: '2000', y: 5 }], + }, + { + id: 'read10-index', + data: [ + { x: '2000', y: 4 }, + { x: '2001', y: 1.5 }, + ], + }, + ], + }); + }); + + test('returns an empty data array when no series are defined', () => { + expect(plotTimeSeriesGraph({})).toEqual({ data: [] }); + }); +}); + +describe('table transformers', () => { + test('getCitationTableData rounds decimal values to one place and preserves zeroes', () => { + const input = { + total: { + [CitationsStatsKey.NCP]: 12.34, + [CitationsStatsKey.TNC]: 20.04, + [CitationsStatsKey.NSC]: 0, + [CitationsStatsKey.ANC]: 1.04, + [CitationsStatsKey.MNC]: 2, + [CitationsStatsKey.NNC]: 3.99, + [CitationsStatsKey.TNRC]: 4.44, + [CitationsStatsKey.ANRC]: 5.05, + [CitationsStatsKey.MNRC]: 6.66, + [CitationsStatsKey.NNRC]: 7.01, + 'self-citations': [], + }, + refereed: { + [CitationsStatsKey.NCP]: 10.55, + [CitationsStatsKey.TNC]: 11.01, + [CitationsStatsKey.NSC]: 0, + [CitationsStatsKey.ANC]: 1, + [CitationsStatsKey.MNC]: 2.01, + [CitationsStatsKey.NNC]: 3.44, + [CitationsStatsKey.TNRC]: 4, + [CitationsStatsKey.ANRC]: 5.94, + [CitationsStatsKey.MNRC]: 6, + [CitationsStatsKey.NNRC]: 7.49, + }, + }; + + expect(getCitationTableData(input)).toEqual({ + numberOfCitingPapers: [12.3, 10.6], + totalCitations: [20, 11], + numberOfSelfCitations: [0, 0], + averageCitations: [1, 1], + medianCitations: [2, 2], + normalizedCitations: [4, 3.4], + refereedCitations: [4.4, 4], + averageRefereedCitations: [5, 5.9], + medianRefereedCitations: [6.7, 6], + normalizedRefereedCitations: [7, 7.5], + }); + }); + + test('getReadsTableData rounds values and uses the total median downloads value for both entries', () => { + const input = { + total: { + [BasicStatsKey.TNR]: 100.04, + [BasicStatsKey.ANR]: 3.49, + [BasicStatsKey.MNR]: 2.01, + [BasicStatsKey.TND]: 80.66, + [BasicStatsKey.AND]: 4.44, + [BasicStatsKey.MND]: 7.77, + [BasicStatsKey.NP]: 0, + [BasicStatsKey.NPC]: 0, + [BasicStatsKey.RND]: 0, + [BasicStatsKey.RNR]: 0, + }, + refereed: { + [BasicStatsKey.TNR]: 50.55, + [BasicStatsKey.ANR]: 2.04, + [BasicStatsKey.MNR]: 1.94, + [BasicStatsKey.TND]: 40.01, + [BasicStatsKey.AND]: 2.05, + [BasicStatsKey.MND]: 9.99, + [BasicStatsKey.NP]: 0, + [BasicStatsKey.NPC]: 0, + [BasicStatsKey.RND]: 0, + [BasicStatsKey.RNR]: 0, + }, + }; + + expect(getReadsTableData(input)).toEqual({ + totalNumberOfReads: [100, 50.5], + averageNumberOfReads: [3.5, 2], + medianNumberOfReads: [2, 1.9], + totalNumberOfDownloads: [80.7, 40], + averageNumberOfDownloads: [4.4, 2], + medianNumberOfDownloads: [7.8, 7.8], + }); + }); + + test('getPapersTableData and getIndicesTableData return rounded output shapes', () => { + const papersInput = { + total: { + [BasicStatsKey.NP]: 10.09, + [BasicStatsKey.NPC]: 4.44, + [BasicStatsKey.AND]: 0, + [BasicStatsKey.ANR]: 0, + [BasicStatsKey.MND]: 0, + [BasicStatsKey.MNR]: 0, + [BasicStatsKey.RND]: 0, + [BasicStatsKey.RNR]: 0, + [BasicStatsKey.TND]: 0, + [BasicStatsKey.TNR]: 0, + }, + refereed: { + [BasicStatsKey.NP]: 8.94, + [BasicStatsKey.NPC]: 3, + [BasicStatsKey.AND]: 0, + [BasicStatsKey.ANR]: 0, + [BasicStatsKey.MND]: 0, + [BasicStatsKey.MNR]: 0, + [BasicStatsKey.RND]: 0, + [BasicStatsKey.RNR]: 0, + [BasicStatsKey.TND]: 0, + [BasicStatsKey.TNR]: 0, + }, + }; + const indicesInput = { + total: { + [TimeSeriesKey.H]: 1.04, + [TimeSeriesKey.M]: 2, + [TimeSeriesKey.G]: 3.95, + [TimeSeriesKey.I10]: undefined, + [TimeSeriesKey.I100]: 0, + [TimeSeriesKey.TORI]: 5.55, + [TimeSeriesKey.RIQ]: 6.01, + [TimeSeriesKey.READ10]: 7.77, + }, + refereed: { + [TimeSeriesKey.H]: 1.95, + [TimeSeriesKey.M]: 2.04, + [TimeSeriesKey.G]: 3, + [TimeSeriesKey.I10]: 4.44, + [TimeSeriesKey.I100]: undefined, + [TimeSeriesKey.TORI]: 5, + [TimeSeriesKey.RIQ]: 6.66, + [TimeSeriesKey.READ10]: 0, + }, + }; + + expect(getPapersTableData(papersInput)).toEqual({ + totalNumberOfPapers: [10.1, 8.9], + totalNormalizedPaperCount: [4.4, 3], + }); + expect(getIndicesTableData(indicesInput)).toEqual({ + hIndex: [1, 1.9], + mIndex: [2, 2], + gIndex: [4, 3], + i10Index: [undefined, 4.4], + i100Index: [0, undefined], + toriIndex: [5.5, 5], + riqIndex: [6, 6.7], + read10Index: [7.8, 0], + }); + }); +}); + +describe('getYearsGraph', () => { + test('builds year buckets, fills gaps, and ignores unrelated property groups', () => { + const input = { + facet_queries: {}, + facet_fields: {} as IFacetCountsFields['facet_fields'], + facet_ranges: {}, + facet_intervals: {}, + facet_heatmaps: {}, + facet_pivot: { + 'property,year': [ + { + count: 2, + field: 'property', + value: 'refereed', + pivot: [ + { field: 'year', value: '2000', count: 2 }, + { field: 'year', value: '2002', count: 3 }, + ], + }, + { + count: 1, + field: 'property', + value: 'notrefereed', + pivot: [{ field: 'year', value: '2001', count: 4 }], + }, + { + count: 1, + field: 'property', + value: 'other', + pivot: [{ field: 'year', value: '1999', count: 99 }], + }, + ], + }, + } as IFacetCountsFields; + + expect(getYearsGraph(input)).toEqual({ + data: [ + { year: 2000, refereed: 2, notrefereed: 0 }, + { year: 2001, refereed: 0, notrefereed: 4 }, + { year: 2002, refereed: 3, notrefereed: 0 }, + ], + keys: ['refereed', 'notrefereed'], + indexBy: 'year', + }); + }); +}); + +describe('getHIndexGraphData', () => { + test('expands bucket counts into one point per paper and caps the result length', () => { + const counts: IBucket[] = [ + { val: 9, count: 2 }, + { val: 5, count: 3 }, + { val: 1, count: 10 }, + ]; + + expect(getHIndexGraphData(counts, 4)).toEqual([ + { x: 1, y: 9 }, + { x: 2, y: 9 }, + { x: 3, y: 5 }, + { x: 4, y: 5 }, + ]); + }); + + test.each([ + { name: 'empty counts', counts: [] as IBucket[], maxDataPoints: 5 }, + { name: 'zero max data points', counts: [{ val: 3, count: 2 }] as IBucket[], maxDataPoints: 0 }, + ])('returns an empty array for $name', ({ counts, maxDataPoints }) => { + expect(getHIndexGraphData(counts, maxDataPoints)).toEqual([]); + }); +}); + +describe('getResultsGraph', () => { + test('normalizes result nodes, defaults missing fields, and groups less common journals as other', () => { + const docs = [ + { bibcode: '2024ApJ..0001A', pubdate: '2024-00-00', title: ['Alpha'], read_count: 5, citation_count: 7 }, + { bibcode: '2024ApJ..0002B', pubdate: '2024-02-00', title: ['Beta'] }, + { bibcode: '2024MNRAS003C', pubdate: '2024-03-15', title: ['Gamma'], read_count: 1, citation_count: 2 }, + { bibcode: '2024AJ...004D', pubdate: '2024-04-20', title: ['Delta'], read_count: 2, citation_count: 1 }, + { bibcode: '2024A&A..005E', pubdate: '2024-05-00', title: ['Epsilon'], read_count: 3, citation_count: 4 }, + { bibcode: '2024Nat..006F', pubdate: '2024-06-30', title: ['Zeta'], read_count: 4, citation_count: 5 }, + { bibcode: '2024Sci..007G', pubdate: '2024-07-00', title: ['Eta'], read_count: 6, citation_count: 8 }, + { bibcode: '2024PASP.008H', pubdate: '2024-08-12', title: ['Theta'], read_count: 7, citation_count: 9 }, + ] as IDocsEntity[]; + + const result = getResultsGraph(docs); + + expect(result.groups).toEqual(['ApJ', 'PASP', 'Sci', 'Nat', 'A&A', 'other']); + expect(result.data).toHaveLength(8); + expect(result.data[0]).toMatchObject({ + bibcode: '2024ApJ..0001A', + pubdate: '2024-01-01', + title: 'Alpha', + read_count: 5, + citation_count: 7, + year: 2024, + pub: 'ApJ', + }); + expect(result.data[0].date.toISOString()).toBe(new Date('2024-01-01').toISOString()); + expect(result.data[1]).toMatchObject({ + bibcode: '2024ApJ..0002B', + pubdate: '2024-02-01', + title: 'Beta', + read_count: 0, + citation_count: 0, + pub: 'ApJ', + }); + expect(result.data[2].pub).toBe('other'); + expect(result.data[3].pub).toBe('other'); + expect(result.data[6].pub).toBe('Sci'); + }); + + test('returns no groups and rewrites all pubs to other when the top five journals are less than 25 percent of results', () => { + const docs = Array.from({ length: 24 }, (_, index) => { + const pub = `J${index.toString().padStart(4, '0')}`; + return { + bibcode: `2024${pub}X`, + pubdate: '2024-01-15', + title: [`Paper ${index + 1}`], + }; + }) as IDocsEntity[]; + + const result = getResultsGraph(docs); + + expect(result.groups).toEqual([]); + expect(result.data.every((item) => item.pub === 'other')).toBe(true); + }); +}); diff --git a/src/components/Visualizations/utils/utils.test.ts b/src/components/Visualizations/utils/utils.test.ts new file mode 100644 index 000000000..5cc0e0c41 --- /dev/null +++ b/src/components/Visualizations/utils/utils.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest'; +import type { Serie } from '@nivo/line'; +import type { YearDatum } from '../types'; +import { getLineGraphXTicks, getQueryWithCondition, getYearGraphTicks, groupBarDatumByYear } from './utils'; + +const makeYearDatum = (year: number, refereed: number, notrefereed: number): YearDatum => ({ + year, + refereed, + notrefereed, +}); + +describe('getLineGraphXTicks', () => { + test('returns evenly spaced ticks across the full x range from mixed series', () => { + const data: Serie[] = [ + { + id: 'first', + data: [ + { x: 2001, y: 1 }, + { x: 2003, y: 2 }, + ], + }, + { + id: 'second', + data: [ + { x: '2002', y: 3 }, + { x: '2006', y: 4 }, + ], + }, + ]; + + expect(getLineGraphXTicks(data, 3)).toEqual([2001, 2003, 2005]); + }); + + test('returns a single tick when all x values are the same', () => { + const data: Serie[] = [ + { + id: 'only', + data: [ + { x: 1999, y: 1 }, + { x: '1999', y: 2 }, + ], + }, + ]; + + expect(getLineGraphXTicks(data, 4)).toEqual([1999]); + }); + + test('returns an empty array for empty input', () => { + expect(getLineGraphXTicks([], 5)).toEqual([]); + }); +}); + +describe('groupBarDatumByYear', () => { + test('groups year data into summed ranges based on max x ticks', () => { + const yearData: YearDatum[] = [ + makeYearDatum(2000, 1, 10), + makeYearDatum(2001, 2, 20), + makeYearDatum(2002, 3, 30), + makeYearDatum(2003, 4, 40), + makeYearDatum(2004, 5, 50), + ]; + + expect(groupBarDatumByYear(yearData, 2, 0, yearData.length)).toEqual([ + { year: '2000 - 2002', refereed: 6, notrefereed: 60 }, + { year: '2003 - 2004', refereed: 9, notrefereed: 90 }, + ]); + }); + + test('returns a single-year label when grouping a single item', () => { + const yearData: YearDatum[] = [makeYearDatum(2010, 7, 11)]; + + expect(groupBarDatumByYear(yearData, 3, 0, 1)).toEqual([{ year: '2010 - 2010', refereed: 7, notrefereed: 11 }]); + }); + + test('returns an empty array when the requested range is empty', () => { + const yearData: YearDatum[] = [makeYearDatum(2010, 7, 11)]; + + expect(groupBarDatumByYear(yearData, 3, 0, 0)).toEqual([]); + }); +}); + +describe('getQueryWithCondition', () => { + test('wraps an unparenthesized query before appending the condition', () => { + expect(getQueryWithCondition('author:einstein', 'year', '[2000 TO 2005]')).toBe( + '(author:einstein) AND year:[2000 TO 2005]', + ); + }); + + test('preserves an already parenthesized query', () => { + expect(getQueryWithCondition('(author:einstein OR author:curie)', 'read_count', '[10 TO *]')).toBe( + '(author:einstein OR author:curie) AND read_count:[10 TO *]', + ); + }); + + test('handles an empty query string using the current implementation behavior', () => { + expect(getQueryWithCondition('', 'citation_count', '[1 TO 5]')).toBe('() AND citation_count:[1 TO 5]'); + }); +}); + +describe('getYearGraphTicks', () => { + test('returns every nth year based on the provided maxTicks step', () => { + const data: YearDatum[] = [ + makeYearDatum(2000, 1, 1), + makeYearDatum(2001, 1, 1), + makeYearDatum(2002, 1, 1), + makeYearDatum(2003, 1, 1), + makeYearDatum(2004, 1, 1), + ]; + + expect(getYearGraphTicks(data, 2)).toEqual([2000, 2002, 2004]); + }); + + test('returns the only year for a single-item input', () => { + expect(getYearGraphTicks([makeYearDatum(1995, 1, 1)], 3)).toEqual([1995]); + }); + + test('returns an empty array for empty input', () => { + expect(getYearGraphTicks([], 2)).toEqual([]); + }); +}); diff --git a/src/lib/useNivoDarkTheme.test.ts b/src/lib/useNivoDarkTheme.test.ts new file mode 100644 index 000000000..b936b91c1 --- /dev/null +++ b/src/lib/useNivoDarkTheme.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest'; + +import { useNivoDarkTheme } from './useNivoDarkTheme'; + +describe('useNivoDarkTheme', () => { + test('returns the expected dark theme shape', () => { + const theme = useNivoDarkTheme(); + + expect(theme).toEqual( + expect.objectContaining({ + background: expect.any(String), + text: expect.objectContaining({ + fill: expect.any(String), + }), + axis: expect.objectContaining({ + ticks: expect.objectContaining({ + text: expect.objectContaining({ + fill: expect.any(String), + }), + }), + }), + tooltip: expect.objectContaining({ + container: expect.objectContaining({ + background: expect.any(String), + }), + }), + legends: expect.objectContaining({ + text: expect.objectContaining({ + fill: expect.any(String), + }), + }), + }), + ); + }); + + test('returns the expected dark theme colors', () => { + const theme = useNivoDarkTheme(); + + expect(theme.background).toBe('#1C1C1C'); + expect(theme.text.fill).toBe('#000000'); + expect(theme.axis.ticks.text.fill).toBe('#ffffff'); + expect(theme.tooltip.container.background).toBe('#000000'); + expect(theme.legends.text.fill).toBe('#ffffff'); + }); +}); From c38e9ccd2f7c9513a171e5b8a3048db253dfdd4c Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:25:43 -0400 Subject: [PATCH 08/10] test: add unit tests for form components and visualization hooks Cover CitationForm author/ORCID validation and submit payloads, KeywordsForm debounced validation and mutation flow, ORCID query key helpers, and useBubblePlot scale construction including log-zero normalization and empty/single-item edge cases. --- src/api/orcid/orcid.test.ts | 61 +++++ .../Forms/CitationForm.test.tsx | 143 +++++++++++ .../Forms/KeywordsForm.test.tsx | 124 ++++++++++ .../Graphs/useBubblePlot.test.tsx | 234 ++++++++++++++++++ 4 files changed, 562 insertions(+) create mode 100644 src/api/orcid/orcid.test.ts create mode 100644 src/components/EmailNotifications/Forms/CitationForm.test.tsx create mode 100644 src/components/EmailNotifications/Forms/KeywordsForm.test.tsx create mode 100644 src/components/Visualizations/Graphs/useBubblePlot.test.tsx diff --git a/src/api/orcid/orcid.test.ts b/src/api/orcid/orcid.test.ts new file mode 100644 index 000000000..43832d3d5 --- /dev/null +++ b/src/api/orcid/orcid.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; + +import { OrcidKeys, orcidKeys } from '@/api/orcid/orcid'; + +describe('orcidKeys', () => { + test('exchangeToken omits user from the query key params', () => { + const params = { + user: { + orcid: '0000-0000-0000-0001', + access_token: 'secret-token', + }, + code: 'auth-code', + state: 'some-state', + }; + + expect(orcidKeys.exchangeToken(params)).toEqual([ + OrcidKeys.EXCHANGE_TOKEN, + { code: 'auth-code', state: 'some-state' }, + ]); + }); + + test('profile preserves formatting flags while omitting user', () => { + const params = { + user: { + orcid: '0000-0000-0000-0001', + access_token: 'secret-token', + }, + full: false, + update: true, + }; + + expect(orcidKeys.profile(params)).toEqual([OrcidKeys.PROFILE, { full: false, update: true }]); + }); + + test('name and getWork keys keep non-user identifiers only', () => { + const user = { + orcid: '0000-0000-0000-0001', + access_token: 'secret-token', + }; + + expect(orcidKeys.name({ user })).toEqual([OrcidKeys.NAME, {}]); + expect(orcidKeys.getWork({ user, putcode: 12345 })).toEqual([OrcidKeys.GET_WORK, { putcode: 12345 }]); + }); + + test('preference keys follow the expected query and mutation shapes', () => { + const params = { + user: { + orcid: '0000-0000-0000-0001', + access_token: 'secret-token', + }, + }; + + expect(orcidKeys.getPreferences(params)).toEqual([OrcidKeys.GET_PREFERENCES, {}]); + expect(orcidKeys.setPreferences()).toEqual([OrcidKeys.SET_PREFERENCES]); + }); + + test('addWorks and removeWorks return stable mutation keys', () => { + expect(orcidKeys.addWorks()).toEqual([OrcidKeys.ADD_WORKS]); + expect(orcidKeys.removeWorks()).toEqual([OrcidKeys.REMOVE_WORKS]); + }); +}); diff --git a/src/components/EmailNotifications/Forms/CitationForm.test.tsx b/src/components/EmailNotifications/Forms/CitationForm.test.tsx new file mode 100644 index 000000000..2c0762ad7 --- /dev/null +++ b/src/components/EmailNotifications/Forms/CitationForm.test.tsx @@ -0,0 +1,143 @@ +import { render } from '@/test-utils'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CitationForm } from './CitationForm'; +import { isValidOrcidId } from '@/api/orcid/models'; + +const addNotificationMock = vi.fn(); +const editNotificationMock = vi.fn(); +const toastMock = vi.fn(); + +vi.mock('@/api/vault/vault', () => ({ + useAddNotification: () => ({ + mutate: addNotificationMock, + isLoading: false, + }), + useEditNotification: () => ({ + mutate: editNotificationMock, + isLoading: false, + }), +})); + +vi.mock('@chakra-ui/react', async () => { + const actual = await vi.importActual('@chakra-ui/react'); + + return { + ...actual, + useToast: () => toastMock, + }; +}); + +describe('CitationForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('renders create mode with empty input and disabled submit state', () => { + render(); + + expect(screen.getByTestId('create-citations-modal')).toBeInTheDocument(); + expect(screen.getByLabelText('new author input')).toHaveValue(''); + expect(screen.queryByText('Notification Name')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'add author' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + }); + + test('validates author names with the component regex and enables add for valid input', async () => { + const { user } = render(); + + const input = screen.getByLabelText('new author input'); + const addButton = screen.getByRole('button', { name: 'add author' }); + + await user.type(input, 'Jane Doe'); + expect(screen.getByText('Invalid')).toBeInTheDocument(); + expect(addButton).toBeDisabled(); + + await user.clear(input); + await user.type(input, 'Doe, Jane'); + + expect(screen.getByText('Author')).toBeInTheDocument(); + expect(addButton).toBeEnabled(); + }); + + test('accepts canonical ORCID ids and rejects invalid ones', async () => { + const validOrcid = '0000-0002-1825-0097'; + const invalidOrcid = '0000-0002-1825-009'; + + expect(isValidOrcidId(validOrcid)).toBe(true); + expect(isValidOrcidId(invalidOrcid)).toBe(false); + + const { user } = render(); + + const input = screen.getByLabelText('new author input'); + const addButton = screen.getByRole('button', { name: 'add author' }); + + await user.type(input, validOrcid); + expect(screen.getByText('Orcid')).toBeInTheDocument(); + expect(addButton).toBeEnabled(); + + await user.clear(input); + await user.type(input, invalidOrcid); + + expect(screen.getByText('Invalid')).toBeInTheDocument(); + expect(addButton).toBeDisabled(); + }); + + test('submits a new citation notification with serialized authors', async () => { + const { user } = render(); + + const input = screen.getByLabelText('new author input'); + + await user.type(input, 'Doe, Jane'); + await user.keyboard('{Enter}'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled()); + + await user.type(input, '0000-0002-1825-0097'); + await user.click(screen.getByRole('button', { name: 'add author' })); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect(addNotificationMock).toHaveBeenCalledTimes(1); + expect(addNotificationMock).toHaveBeenCalledWith( + { + data: 'author:"Doe, Jane" OR orcid:"0000-0002-1825-0097"', + template: 'citations', + type: 'template', + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + expect(editNotificationMock).not.toHaveBeenCalled(); + }); + + test('renders edit mode name and submits existing authors with updated name', async () => { + const notification = { + id: 42, + name: 'Original Name', + data: 'author:"Doe, Jane" OR orcid:"0000-0002-1825-0097"', + }; + + const { user } = render(); + + const nameInput = screen.getByDisplayValue('Original Name'); + expect(nameInput).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled(); + + await user.clear(nameInput); + await user.type(nameInput, 'Updated Name'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + expect(editNotificationMock).toHaveBeenCalledTimes(1); + expect(editNotificationMock).toHaveBeenCalledWith( + { + id: 42, + data: 'author:"Doe, Jane" OR orcid:"0000-0002-1825-0097"', + name: 'Updated Name', + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + expect(addNotificationMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/EmailNotifications/Forms/KeywordsForm.test.tsx b/src/components/EmailNotifications/Forms/KeywordsForm.test.tsx new file mode 100644 index 000000000..8df36c553 --- /dev/null +++ b/src/components/EmailNotifications/Forms/KeywordsForm.test.tsx @@ -0,0 +1,124 @@ +import { render, screen, fireEvent, waitFor } from '@/test-utils'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { KeywordsForm } from './KeywordsForm'; +import { isValidKeyword } from './Utils'; + +const mocks = vi.hoisted(() => ({ + addMutation: vi.fn(), + editMutation: vi.fn(), + toast: vi.fn(), +})); + +vi.mock('@/api/vault/vault', () => ({ + useAddNotification: () => ({ + mutate: mocks.addMutation, + isLoading: false, + }), + useEditNotification: () => ({ + mutate: mocks.editMutation, + isLoading: false, + }), +})); + +vi.mock('@chakra-ui/react', async () => { + const actual = await vi.importActual('@chakra-ui/react'); + return { + ...actual, + useToast: () => mocks.toast, + }; +}); + +describe('KeywordsForm', () => { + beforeEach(() => { + vi.useFakeTimers(); + mocks.addMutation.mockReset(); + mocks.editMutation.mockReset(); + mocks.toast.mockReset(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test('renders correctly with initial create state', () => { + render(); + + expect(screen.getByTestId('create-keyword-modal')).toBeInTheDocument(); + expect(screen.getByText(/weekly updates on the most recent/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/notification name/i)).not.toBeInTheDocument(); + expect(screen.getByTestId('keyword-input')).toHaveValue(''); + expect(screen.getByRole('button', { name: /submit/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + }); + + test('shows debounced validation feedback for an invalid keyword', async () => { + render(); + + const keywordInput = screen.getByTestId('keyword-input'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + const invalidKeyword = '('; + + expect(isValidKeyword(invalidKeyword)).toBe(false); + + fireEvent.change(keywordInput, { target: { value: invalidKeyword } }); + + expect(screen.queryByText(/invalid keyword syntax/i)).not.toBeInTheDocument(); + expect(submitButton).toBeEnabled(); + + await vi.advanceTimersByTimeAsync(500); + + await waitFor(() => { + expect(screen.getByText(/invalid keyword syntax/i)).toBeInTheDocument(); + }); + expect(submitButton).toBeDisabled(); + }); + + test('enables submit for a valid keyword after debounce', async () => { + render(); + + const keywordInput = screen.getByTestId('keyword-input'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + const validKeyword = 'alpha (beta)'; + + expect(isValidKeyword(validKeyword)).toBe(true); + + fireEvent.change(keywordInput, { target: { value: validKeyword } }); + await vi.advanceTimersByTimeAsync(500); + + await waitFor(() => { + expect(screen.queryByText(/invalid keyword syntax/i)).not.toBeInTheDocument(); + }); + expect(submitButton).toBeEnabled(); + }); + + test('submits a valid keyword through the add notification mutation', async () => { + const onClose = vi.fn(); + const onUpdated = vi.fn(); + + render(); + + const keywordInput = screen.getByTestId('keyword-input'); + const submitButton = screen.getByRole('button', { name: /submit/i }); + + fireEvent.change(keywordInput, { target: { value: 'star OR planet' } }); + await vi.advanceTimersByTimeAsync(500); + + fireEvent.click(submitButton); + + expect(mocks.addMutation).toHaveBeenCalledTimes(1); + expect(mocks.addMutation).toHaveBeenCalledWith( + { + type: 'template', + template: 'keyword', + data: 'star OR planet', + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + expect(mocks.editMutation).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + expect(onUpdated).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Visualizations/Graphs/useBubblePlot.test.tsx b/src/components/Visualizations/Graphs/useBubblePlot.test.tsx new file mode 100644 index 000000000..69ab5fd89 --- /dev/null +++ b/src/components/Visualizations/Graphs/useBubblePlot.test.tsx @@ -0,0 +1,234 @@ +import { renderHook } from '@testing-library/react'; +import { useColorMode } from '@chakra-ui/react'; +import type { IBubblePlot, IBubblePlotNodeData } from '../types'; +import { useBubblePlot } from './useBubblePlot'; +import { describe, expect, test } from 'vitest'; +import { afterEach, vi } from 'vitest'; + +vi.mock('@chakra-ui/react', async () => { + const actual = await vi.importActual('@chakra-ui/react'); + + return { + ...actual, + useColorMode: vi.fn(), + }; +}); + +const mockedUseColorMode = vi.mocked(useColorMode); + +const createNode = (overrides: Partial = {}): IBubblePlotNodeData => ({ + bibcode: 'bibcode-1', + pubdate: '2000-01-01', + title: 'Paper', + read_count: 10, + citation_count: 5, + date: new Date('2000-01-01T00:00:00.000Z'), + year: 2000, + pub: 'AAS', + ...overrides, +}); + +const createGraph = (data: IBubblePlotNodeData[], groups?: string[]): IBubblePlot => ({ + data, + groups, +}); + +afterEach(() => { + vi.clearAllMocks(); + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); +}); + +describe('useBubblePlot', () => { + test('builds linear x and y scales, year radius scale, and group colors from graph data', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); + + const graph = createGraph( + [ + createNode({ + bibcode: 'paper-1', + citation_count: 5, + read_count: 10, + year: 2000, + pub: 'AAS', + }), + createNode({ + bibcode: 'paper-2', + citation_count: 15, + read_count: 30, + year: 2010, + pub: 'PhRvL', + }), + ], + ['AAS', 'PhRvL'], + ); + + const { result } = renderHook(() => + useBubblePlot({ + graph, + xKey: 'citation_count', + yKey: 'read_count', + rKey: 'year', + xScaleType: 'linear', + yScaleType: 'linear', + width: 400, + height: 200, + }), + ); + + expect(result.current).toMatchObject({ + textColor: '#000000', + }); + expect(result.current.groupColor.domain()).toEqual(['AAS', 'PhRvL']); + expect(result.current.groupColor('AAS')).toBe('hsla(282, 80%, 52%, 0.9)'); + expect(result.current.groupColor('PhRvL')).toBe('hsla(1, 80%, 51%, 0.9)'); + expect(result.current.xScaleFn.domain()).toEqual([5, 15]); + expect(result.current.xScaleFn.range()).toEqual([0, 400]); + expect(result.current.yScaleFn.domain()).toEqual([10, 30]); + expect(result.current.yScaleFn.range()).toEqual([200, 0]); + expect(result.current.rScaleFn.domain()).toEqual([2000, 2010]); + expect(result.current.rScaleFn.range()).toEqual([2, 14]); + }); + + test('uses a time scale for date x values and dark mode text color', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'dark' } as ReturnType); + + const firstDate = new Date('2001-01-01T00:00:00.000Z'); + const secondDate = new Date('2003-06-01T00:00:00.000Z'); + const graph = createGraph([ + createNode({ bibcode: 'paper-1', date: firstDate }), + createNode({ bibcode: 'paper-2', date: secondDate }), + ]); + + const { result } = renderHook(() => + useBubblePlot({ + graph, + xKey: 'date', + yKey: 'citation_count', + rKey: 'citation_count', + xScaleType: 'linear', + yScaleType: 'linear', + width: 600, + height: 300, + }), + ); + + expect(result.current.textColor).toBe('#ffffff'); + expect(result.current.xScaleFn.domain()).toEqual([firstDate, secondDate]); + expect(result.current.xScaleFn.range()).toEqual([0, 600]); + expect(result.current.rScaleFn.range()).toEqual([4, 26]); + }); + + test('normalizes zero minimum values to one for log scales', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); + + const graph = createGraph([ + createNode({ + bibcode: 'paper-1', + citation_count: 0, + read_count: 0, + }), + createNode({ + bibcode: 'paper-2', + citation_count: 100, + read_count: 1000, + }), + ]); + + const { result } = renderHook(() => + useBubblePlot({ + graph, + xKey: 'citation_count', + yKey: 'read_count', + rKey: 'citation_count', + xScaleType: 'log', + yScaleType: 'log', + width: 500, + height: 250, + }), + ); + + expect(result.current.xScaleFn.domain()).toEqual([1, 100]); + expect(result.current.yScaleFn.domain()).toEqual([1, 1000]); + expect(result.current.xScaleFn.range()).toEqual([0, 500]); + expect(result.current.yScaleFn.range()).toEqual([250, 0]); + }); + + test('returns usable scale objects for empty datasets', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); + + const { result } = renderHook(() => + useBubblePlot({ + graph: createGraph([], []), + xKey: 'citation_count', + yKey: 'read_count', + rKey: 'citation_count', + xScaleType: 'linear', + yScaleType: 'linear', + width: 320, + height: 180, + }), + ); + + expect(result.current.groupColor.domain()).toEqual([]); + expect(result.current.xScaleFn.range()).toEqual([0, 320]); + expect(result.current.yScaleFn.range()).toEqual([180, 0]); + expect(result.current.rScaleFn.range()).toEqual([4, 26]); + expect(result.current.xScaleFn.domain().every((value) => Number.isNaN(value))).toBe(true); + expect(result.current.yScaleFn.domain().every((value) => Number.isNaN(value))).toBe(true); + expect(result.current.rScaleFn.domain().every((value) => Number.isNaN(value))).toBe(true); + }); + + test('ignores missing keyed values when computing extents', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); + + const graph = createGraph([ + createNode({ + bibcode: 'paper-1', + citation_count: undefined as unknown as number, + }), + createNode({ + bibcode: 'paper-2', + citation_count: 42, + }), + ]); + + const { result } = renderHook(() => + useBubblePlot({ + graph, + xKey: 'citation_count', + yKey: 'read_count', + rKey: 'citation_count', + xScaleType: 'linear', + yScaleType: 'linear', + width: 400, + height: 200, + }), + ); + + expect(result.current.xScaleFn.domain()).toEqual([42, 42]); + expect(result.current.rScaleFn.domain()).toEqual([42, 42]); + }); + + test('preserves single-item domains', () => { + mockedUseColorMode.mockReturnValue({ colorMode: 'light' } as ReturnType); + + const graph = createGraph([createNode({ citation_count: 12, read_count: 7, year: 1999 })]); + + const { result } = renderHook(() => + useBubblePlot({ + graph, + xKey: 'citation_count', + yKey: 'read_count', + rKey: 'year', + xScaleType: 'linear', + yScaleType: 'linear', + width: 100, + height: 50, + }), + ); + + expect(result.current.xScaleFn.domain()).toEqual([12, 12]); + expect(result.current.yScaleFn.domain()).toEqual([7, 7]); + expect(result.current.rScaleFn.domain()).toEqual([1999, 1999]); + }); +}); From cc38269cbdb70833ca9be89e59f271add4577aca Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:28:41 -0400 Subject: [PATCH 09/10] test: add unit tests for ArxivForm selection and ORCID remove helpers Cover ArxivForm parent/child category toggle behavior and submit payload collapsing, plus pure helpers from useRemoveWorks: getFulfilled, getRejected, and findPutcodeInProfile including numeric putcode coercion. --- .../Forms/ArxivForm.test.tsx | 129 ++++++++++++++++++ src/lib/orcid/useRemoveWorks.test.ts | 113 +++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/components/EmailNotifications/Forms/ArxivForm.test.tsx create mode 100644 src/lib/orcid/useRemoveWorks.test.ts diff --git a/src/components/EmailNotifications/Forms/ArxivForm.test.tsx b/src/components/EmailNotifications/Forms/ArxivForm.test.tsx new file mode 100644 index 000000000..82d01c9da --- /dev/null +++ b/src/components/EmailNotifications/Forms/ArxivForm.test.tsx @@ -0,0 +1,129 @@ +import { render, screen, waitFor } from '@/test-utils'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { ArxivForm } from './ArxivForm'; +import { arxivModel } from '../ArxivModel'; + +const mocks = vi.hoisted(() => ({ + addMutation: vi.fn(), + editMutation: vi.fn(), + toast: vi.fn(), +})); + +vi.mock('@/api/vault/vault', () => ({ + useAddNotification: () => ({ + mutate: mocks.addMutation, + isLoading: false, + }), + useEditNotification: () => ({ + mutate: mocks.editMutation, + isLoading: false, + }), +})); + +vi.mock('@chakra-ui/react', async () => { + const actual = await vi.importActual('@chakra-ui/react'); + return { + ...actual, + useToast: () => mocks.toast, + }; +}); + +const getCheckboxByValue = (value: string) => { + const input = document.querySelector(`input[type="checkbox"][value="${value}"]`); + expect(input).not.toBeNull(); + return input as HTMLInputElement; +}; + +const clickCheckboxByValue = async (user: ReturnType['user'], value: string) => { + const input = getCheckboxByValue(value); + const label = input.closest('label'); + expect(label).not.toBeNull(); + await user.click(label as HTMLLabelElement); +}; + +describe('ArxivForm', () => { + beforeEach(() => { + mocks.addMutation.mockReset(); + mocks.editMutation.mockReset(); + mocks.toast.mockReset(); + }); + + test('clicking a parent selects all children, then deselects all children when clicked again', async () => { + const { user } = render(); + + const parent = getCheckboxByValue('astro-ph'); + await clickCheckboxByValue(user, 'astro-ph'); + + expect(parent).toBeChecked(); + + await user.click(screen.getByRole('button', { name: 'expand Astrophysics' })); + + expect(getCheckboxByValue('astro-ph.CO')).toBeChecked(); + expect(getCheckboxByValue('astro-ph.EP')).toBeChecked(); + expect(getCheckboxByValue('astro-ph.GA')).toBeChecked(); + expect(getCheckboxByValue('astro-ph.HE')).toBeChecked(); + expect(getCheckboxByValue('astro-ph.IM')).toBeChecked(); + expect(getCheckboxByValue('astro-ph.SR')).toBeChecked(); + + await clickCheckboxByValue(user, 'astro-ph'); + + expect(parent).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.CO')).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.EP')).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.GA')).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.HE')).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.IM')).not.toBeChecked(); + expect(getCheckboxByValue('astro-ph.SR')).not.toBeChecked(); + }); + + test('clicking a child toggles only that child and updates the parent to indeterminate', async () => { + const { user } = render(); + + await user.click(screen.getByRole('button', { name: 'expand Astrophysics' })); + + const parent = getCheckboxByValue('astro-ph'); + const child = getCheckboxByValue('astro-ph.EP'); + + expect(parent).not.toBeChecked(); + expect(child).not.toBeChecked(); + + await clickCheckboxByValue(user, 'astro-ph.EP'); + + expect(child).toBeChecked(); + expect(parent).toBePartiallyChecked(); + + await clickCheckboxByValue(user, 'astro-ph.EP'); + + expect(child).not.toBeChecked(); + expect(parent).not.toBeChecked(); + }); + + test('submitting with all children selected collapses the payload to the parent class key', async () => { + const { user } = render(); + + await user.click(screen.getByRole('button', { name: 'expand Astrophysics' })); + + for (const childKey of Object.keys(arxivModel['astro-ph'].children)) { + await clickCheckboxByValue(user, childKey); + } + + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => { + expect(mocks.addMutation).toHaveBeenCalledTimes(1); + }); + + expect(mocks.addMutation).toHaveBeenCalledWith( + { + type: 'template', + template: 'arxiv', + data: null, + classes: ['astro-ph'], + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + expect(mocks.editMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/orcid/useRemoveWorks.test.ts b/src/lib/orcid/useRemoveWorks.test.ts new file mode 100644 index 000000000..515c9c768 --- /dev/null +++ b/src/lib/orcid/useRemoveWorks.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'vitest'; +import { IOrcidProfile } from '@/api/orcid/types'; +import { findPutcodeInProfile, getFulfilled, getRejected } from './useRemoveWorks'; + +const createProfileEntry = (putcode: string | number) => ({ + identifier: `id-${putcode}`, + status: 'verified' as const, + title: `Title ${putcode}`, + pubyear: '2024', + pubmonth: '06', + updated: '2024-06-03', + putcode, + source: ['ADS'], +}); + +describe('getFulfilled', () => { + test('returns keys for fulfilled entries when results are mixed', () => { + const entries: Record> = { + alpha: { status: 'fulfilled', value: undefined }, + beta: { status: 'rejected', reason: new Error('failed') }, + gamma: { status: 'fulfilled', value: undefined }, + }; + + expect(getFulfilled(entries)).toStrictEqual(['alpha', 'gamma']); + }); + + test('returns all keys when every entry is fulfilled', () => { + const entries: Record> = { + alpha: { status: 'fulfilled', value: undefined }, + beta: { status: 'fulfilled', value: undefined }, + }; + + expect(getFulfilled(entries)).toStrictEqual(['alpha', 'beta']); + }); + + test('returns an empty array when every entry is rejected', () => { + const entries: Record> = { + alpha: { status: 'rejected', reason: new Error('failed') }, + beta: { status: 'rejected', reason: new Error('failed again') }, + }; + + expect(getFulfilled(entries)).toStrictEqual([]); + }); + + test('returns an empty array for an empty object', () => { + expect(getFulfilled({})).toStrictEqual([]); + }); +}); + +describe('getRejected', () => { + test('returns keys for rejected entries when results are mixed', () => { + const entries: Record> = { + alpha: { status: 'fulfilled', value: undefined }, + beta: { status: 'rejected', reason: new Error('failed') }, + gamma: { status: 'rejected', reason: new Error('failed again') }, + }; + + expect(getRejected(entries)).toStrictEqual(['beta', 'gamma']); + }); + + test('returns an empty array when every entry is fulfilled', () => { + const entries: Record> = { + alpha: { status: 'fulfilled', value: undefined }, + beta: { status: 'fulfilled', value: undefined }, + }; + + expect(getRejected(entries)).toStrictEqual([]); + }); + + test('returns all keys when every entry is rejected', () => { + const entries: Record> = { + alpha: { status: 'rejected', reason: new Error('failed') }, + beta: { status: 'rejected', reason: new Error('failed again') }, + }; + + expect(getRejected(entries)).toStrictEqual(['alpha', 'beta']); + }); + + test('returns an empty array for an empty object', () => { + expect(getRejected({})).toStrictEqual([]); + }); +}); + +describe('findPutcodeInProfile', () => { + test('finds a matching entry using numeric comparison across string and number putcodes', () => { + const profile: IOrcidProfile = { + first: createProfileEntry(101), + second: createProfileEntry('202'), + }; + + expect(findPutcodeInProfile('101', profile)).toBe('first'); + expect(findPutcodeInProfile('202', profile)).toBe('second'); + }); + + test('returns undefined when no entry matches the putcode', () => { + const profile: IOrcidProfile = { + first: createProfileEntry('101'), + second: createProfileEntry(202), + }; + + expect(findPutcodeInProfile('999', profile)).toBeUndefined(); + }); + + test('returns the first matching entry key when multiple entries share the same numeric putcode', () => { + const profile: IOrcidProfile = { + first: createProfileEntry('101'), + second: createProfileEntry(101), + third: createProfileEntry('303'), + }; + + expect(findPutcodeInProfile('101', profile)).toBe('first'); + }); +}); From dc68700d34fa92e312e73c5dcb5180fff7632a17 Mon Sep 17 00:00:00 2001 From: Tim Hostetler <6970899+thostetler@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:34:13 -0400 Subject: [PATCH 10/10] test: add unit tests for ORCID helpers and QueryForm state machine Cover convertDocType all mappings, findWorkInProfile nil/array/string lookups, mergeOrcidMissingRecords deduplication, and QueryForm submit flow including vault search trigger, success/error paths, and frequency selection. --- .../Forms/QueryForm.test.tsx | 220 ++++++++++++++++++ src/lib/orcid/helpers.test.ts | 173 ++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/components/EmailNotifications/Forms/QueryForm.test.tsx create mode 100644 src/lib/orcid/helpers.test.ts diff --git a/src/components/EmailNotifications/Forms/QueryForm.test.tsx b/src/components/EmailNotifications/Forms/QueryForm.test.tsx new file mode 100644 index 000000000..b89862dcd --- /dev/null +++ b/src/components/EmailNotifications/Forms/QueryForm.test.tsx @@ -0,0 +1,220 @@ +import { render, screen, waitFor } from '@/test-utils'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { QueryForm } from './QueryForm'; + +const mocks = vi.hoisted(() => ({ + addNotification: vi.fn(), + toast: vi.fn(), + useVaultSearch: vi.fn(), + searchState: { + data: undefined as { qid: string } | undefined, + isFetching: false, + error: null as Error | null, + }, +})); + +vi.mock('@/api/vault/vault', () => ({ + useAddNotification: () => ({ + mutate: mocks.addNotification, + isLoading: false, + }), + useVaultSearch: (query: unknown, options: unknown) => mocks.useVaultSearch(query, options), +})); + +vi.mock('@chakra-ui/react', async () => { + const actual = await vi.importActual('@chakra-ui/react'); + return { + ...actual, + useToast: () => mocks.toast, + }; +}); + +type MockOption = { id: string | number; label: string; value: string }; +type MockSelectProps = { + id: string; + label: unknown; + options: MockOption[]; + value: MockOption; + onChange: (o: MockOption) => void; +}; + +vi.mock('@/components/Select', () => ({ + Select: ({ id, label, options, value, onChange }: MockSelectProps) => ( + + ), +})); + +describe('QueryForm', () => { + const query = { q: 'author:"Ada Lovelace"' }; + + beforeEach(() => { + mocks.addNotification.mockReset(); + mocks.toast.mockReset(); + mocks.useVaultSearch.mockReset(); + mocks.searchState.data = undefined; + mocks.searchState.isFetching = false; + mocks.searchState.error = null; + + mocks.useVaultSearch.mockImplementation((_query, options?: { enabled?: boolean }) => ({ + data: options?.enabled ? mocks.searchState.data : undefined, + isFetching: options?.enabled ? mocks.searchState.isFetching : false, + error: options?.enabled ? mocks.searchState.error : null, + })); + }); + + const renderForm = () => + render(, { + initialStore: { + query, + }, + }); + + test('renders with the current query and keeps submit disabled when name is empty', () => { + renderForm(); + + expect(screen.getByDisplayValue(query.q)).toHaveAttribute('readonly'); + expect(screen.getByTestId('create-query-modal')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Submit' })).toBeDisabled(); + expect(mocks.useVaultSearch).toHaveBeenLastCalledWith( + query, + expect.objectContaining({ enabled: false, staleTime: 0 }), + ); + }); + + test('enables submit once a notification name is typed', async () => { + const { user } = renderForm(); + + await user.type(screen.getByTestId('create-query-name'), 'Saved query alert'); + + expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled(); + }); + + test('submit switches the search hook to enabled', async () => { + mocks.searchState.isFetching = true; + + const { user } = renderForm(); + + await user.type(screen.getByTestId('create-query-name'), 'Saved query alert'); + await user.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => { + expect(mocks.useVaultSearch).toHaveBeenLastCalledWith( + query, + expect.objectContaining({ enabled: true, staleTime: 0 }), + ); + }); + }); + + test('adds a notification with the returned qid after a successful search', async () => { + mocks.searchState.isFetching = true; + + const { rerender, user } = renderForm(); + + await user.type(screen.getByTestId('create-query-name'), 'Saved query alert'); + await user.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => { + expect(mocks.useVaultSearch).toHaveBeenLastCalledWith( + query, + expect.objectContaining({ enabled: true, staleTime: 0 }), + ); + }); + + mocks.searchState.isFetching = false; + mocks.searchState.data = { qid: 'Q123' }; + + rerender(); + + await waitFor(() => { + expect(mocks.addNotification).toHaveBeenCalledWith( + { + qid: 'Q123', + frequency: 'daily', + name: 'Saved query alert', + type: 'query', + active: true, + stateful: true, + }, + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + }); + }); + + test('shows an error toast when the search fails', async () => { + mocks.searchState.isFetching = true; + + const { rerender, user } = renderForm(); + + await user.type(screen.getByTestId('create-query-name'), 'Saved query alert'); + await user.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => { + expect(mocks.useVaultSearch).toHaveBeenLastCalledWith( + query, + expect.objectContaining({ enabled: true, staleTime: 0 }), + ); + }); + + mocks.searchState.isFetching = false; + mocks.searchState.error = new Error('Search failed'); + + rerender(); + + await waitFor(() => { + expect(mocks.toast).toHaveBeenCalledWith({ + status: 'error', + title: 'Error', + description: 'Search failed', + }); + }); + expect(mocks.addNotification).not.toHaveBeenCalled(); + }); + + test('submits the selected frequency value', async () => { + mocks.searchState.isFetching = true; + + const { rerender, user } = renderForm(); + + await user.type(screen.getByTestId('create-query-name'), 'Weekly alert'); + await user.selectOptions(screen.getByTestId('frequency-select'), 'weekly'); + await user.click(screen.getByRole('button', { name: /submit/i })); + + await waitFor(() => { + expect(mocks.useVaultSearch).toHaveBeenLastCalledWith( + query, + expect.objectContaining({ enabled: true, staleTime: 0 }), + ); + }); + + mocks.searchState.isFetching = false; + mocks.searchState.data = { qid: 'Q999' }; + + rerender(); + + await waitFor(() => { + expect(mocks.addNotification).toHaveBeenCalledWith( + expect.objectContaining({ + qid: 'Q999', + frequency: 'weekly', + name: 'Weekly alert', + }), + expect.objectContaining({ + onSettled: expect.any(Function), + }), + ); + }); + }); +}); diff --git a/src/lib/orcid/helpers.test.ts b/src/lib/orcid/helpers.test.ts new file mode 100644 index 000000000..0882469e3 --- /dev/null +++ b/src/lib/orcid/helpers.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'vitest'; +import type { IOrcidProfile } from '@/api/orcid/types'; +import type { IDocsEntity } from '@/api/search/types'; +import { convertDocType, findWorkInProfile, mergeOrcidMissingRecords } from './helpers'; + +const createProfileEntry = (identifier: string) => ({ + identifier, + status: 'verified' as const, + title: `Title for ${identifier}`, + pubyear: '2024', + pubmonth: '06', + updated: '2024-06-03', + putcode: `putcode-${identifier}`, + source: ['ADS'], +}); + +const createDoc = (overrides: Partial): IDocsEntity => + ({ + identifier: ['bibcode:default'], + pubdate: '2024-06-03', + title: ['Default title'], + ...overrides, + } as IDocsEntity); + +describe('convertDocType', () => { + test.each([ + ['article', 'JOURNAL_ARTICLE'], + ['inproceedings', 'CONFERENCE_PAPER'], + ['abstract', 'CONFERENCE_ABSTRACT'], + ['eprint', 'WORKING_PAPER'], + ['phdthesis', 'DISSERTATION'], + ['techreport', 'RESEARCH_TECHNIQUE'], + ['inbook', 'BOOK_CHAPTER'], + ['circular', 'RESEARCH_TOOL'], + ['book', 'BOOK'], + ['proceedings', 'BOOK'], + ['bookreview', 'BOOK_REVIEW'], + ['erratum', 'JOURNAL_ARTICLE'], + ['newsletter', 'NEWSLETTER_ARTICLE'], + ['catalog', 'DATA-SET'], + ['intechreport', 'RESEARCH_TECHNIQUE'], + ['mastersthesis', 'DISSERTATION'], + ['software', 'RESEARCH_TECHNIQUE'], + ['talk', 'LECTURE_SPEECH'], + ['dataset', 'DATA-SET'], + ['instrument', 'PHYSICAL-OBJECT'], + ['service', 'DATA-SET'], + ['obituary', 'OTHER'], + ['pressrelease', 'OTHER'], + ['proposal', 'OTHER'], + ['editorial', 'OTHER'], + ['misc', 'OTHER'], + ['unknown-doc-type', 'OTHER'], + ])('maps %s to %s', (docType, expected) => { + expect(convertDocType(docType)).toBe(expected); + }); +}); + +describe('findWorkInProfile', () => { + const profile: IOrcidProfile = { + alpha: createProfileEntry('alpha'), + beta: createProfileEntry('beta'), + }; + + test.each([ + [null, profile], + ['', profile], + [[], profile], + ['alpha', null], + ['alpha', {}], + ])('returns null for nil or empty inputs: %j', (identifier, currentProfile) => { + expect(findWorkInProfile(identifier as string | string[], currentProfile as IOrcidProfile)).toBeNull(); + }); + + test('returns the matching profile entry for a string identifier', () => { + expect(findWorkInProfile('alpha', profile)).toBe(profile.alpha); + }); + + test('returns null when a string identifier is not in the profile', () => { + expect(findWorkInProfile('missing', profile)).toBeNull(); + }); + + test('returns the first matching entry when given an identifier array', () => { + expect(findWorkInProfile(['missing', 'beta', 'alpha'], profile)).toBe(profile.beta); + }); + + test('returns null when no identifiers in the array match the profile', () => { + expect(findWorkInProfile(['missing', 'also-missing'], profile)).toBeNull(); + }); +}); + +describe('mergeOrcidMissingRecords', () => { + test('adds only docs whose identifiers are not already present in the profile', () => { + const existingEntry = createProfileEntry('existing:bibcode'); + const profile: IOrcidProfile = { + 'existing:bibcode': existingEntry, + }; + const missing: IDocsEntity[] = [ + createDoc({ + identifier: ['existing:bibcode', 'alt:existing'], + pubdate: '2024-01-15', + title: ['Existing title should be ignored'], + }), + createDoc({ + identifier: ['new:bibcode', 'alt:new'], + pubdate: '2024-02-20', + title: ['New title'], + }), + ]; + + const merged = mergeOrcidMissingRecords(missing, profile); + + expect(merged).toStrictEqual({ + 'existing:bibcode': existingEntry, + 'new:bibcode': { + identifier: 'new:bibcode', + pubyear: '2024', + pubmonth: '02', + putcode: null, + source: [], + status: null, + title: 'New title', + updated: null, + }, + }); + expect(merged['existing:bibcode']).toBe(existingEntry); + }); + + test('preserves the original profile entries unchanged when every missing doc already exists', () => { + const existingEntry = createProfileEntry('existing:bibcode'); + const profile: IOrcidProfile = { + 'existing:bibcode': existingEntry, + }; + const missing: IDocsEntity[] = [ + createDoc({ + identifier: ['other:id', 'existing:bibcode'], + pubdate: '2023-12', + title: ['Should not be merged'], + }), + ]; + + const merged = mergeOrcidMissingRecords(missing, profile); + + expect(merged).toStrictEqual(profile); + expect(merged['existing:bibcode']).toBe(existingEntry); + }); + + test('uses the first identifier and first title when creating a new profile entry', () => { + const merged = mergeOrcidMissingRecords( + [ + createDoc({ + identifier: ['primary:id', 'secondary:id'], + pubdate: '2023-12', + title: ['Primary title', 'Secondary title'], + }), + ], + {}, + ); + + expect(merged).toStrictEqual({ + 'primary:id': { + identifier: 'primary:id', + pubyear: '2023', + pubmonth: '12', + putcode: null, + source: [], + status: null, + title: 'Primary title', + updated: null, + }, + }); + }); +});