From 0ea7f55d028ebd0100087035880b781f9ed6f974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Jim=C3=A9nez?= Date: Mon, 6 Jan 2025 20:24:42 +0000 Subject: [PATCH 1/5] Generate meaningful bibliography Ids from DOI import --- package.json | 1 + src/bibliography/domain/GenerateIds.test.ts | 63 ++++++++++++++ src/bibliography/domain/GenerateIds.ts | 27 ++++++ src/bibliography/ui/BibliographyEntryForm.tsx | 86 ++++++++++++------- yarn.lock | 5 ++ 5 files changed, 152 insertions(+), 30 deletions(-) create mode 100644 src/bibliography/domain/GenerateIds.test.ts create mode 100644 src/bibliography/domain/GenerateIds.ts diff --git a/package.json b/package.json index 2073d50b4..9a33c2a82 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "remove-markdown": "^0.3.0", "rgb-hex": "^3.0.0", "romans": "^2.0.4", + "stopwords": "^0.0.9", "stylelint-config-standard": "^24.0.0", "typescript": "^4.0.2", "unified": "^9.2.1", diff --git a/src/bibliography/domain/GenerateIds.test.ts b/src/bibliography/domain/GenerateIds.test.ts new file mode 100644 index 000000000..5a22d82a9 --- /dev/null +++ b/src/bibliography/domain/GenerateIds.test.ts @@ -0,0 +1,63 @@ +import { generateIds } from './GenerateIds' +import { CslData } from 'bibliography/domain/BibliographyEntry' + +const testEntry: CslData = { + author: [{ family: 'Doe', given: 'John' }], + title: 'The Quick Brown Fox Jumps Over the Lazy Dog', + issued: { 'date-parts': [[2023]] }, + language: 'en', +} + +describe('generateIds', () => { + test('basic ID generation', () => { + const result = generateIds(testEntry) + expect(result).toBe('doe2023quick') + }) + + test('ID generation with missing author', () => { + const entryWithNoAuthor = { ...testEntry, author: undefined } + const result = generateIds(entryWithNoAuthor) + expect(result).toBe('unknownauthor2023quick') + }) + + test('ID generation with missing year', () => { + const entryWithNoYear = { ...testEntry, issued: undefined } + const result = generateIds(entryWithNoYear) + expect(result).toBe('doe9999quick') + }) + + test('ID generation with missing title', () => { + const entryWithNoTitle = { ...testEntry, title: undefined } + const result = generateIds(entryWithNoTitle) + expect(result).toBe('doe2023unknowntitle') + }) + + test('ID generation with all significant words as stop words', () => { + const entryWithOnlyStopWords = { + ...testEntry, + title: 'The Of And But Or Nor For', + } + const result = generateIds(entryWithOnlyStopWords) + expect(result).toBe('doe2023unknowntitle') + }) + + test('ID generation with different language (German)', () => { + const germanEntry = { + ...testEntry, + language: 'de', + title: 'Der Schnelle Braune Fuchs Springt Über den Faulen Hund', + } + const result = generateIds(germanEntry) + expect(result).toBe('doe2023schnelle') + }) + + test('ID generation with language not supported', () => { + const unsupportedLangEntry = { + ...testEntry, + language: 'ru', + title: 'Быстрый Коричневый Лис Перепрыгивает Через Ленивую Собаку', + } + const result = generateIds(unsupportedLangEntry) + expect(result).toBe('doe2023быстрый') + }) +}) diff --git a/src/bibliography/domain/GenerateIds.ts b/src/bibliography/domain/GenerateIds.ts new file mode 100644 index 000000000..24c8f44fb --- /dev/null +++ b/src/bibliography/domain/GenerateIds.ts @@ -0,0 +1,27 @@ +import { CslData } from 'bibliography/domain/BibliographyEntry' +import stopwords from 'stopwords' + +const STOPWORDS = { + en: new Set(stopwords.english), + de: new Set(stopwords.german), + it: new Set(stopwords.italian), + es: new Set(stopwords.spanish), + fr: new Set(stopwords.french), +} + +export function generateIds(entry: CslData): string { + const language = entry.language || 'en' + const stopwordSet = STOPWORDS[language] || STOPWORDS.en + + const author = entry.author?.[0]?.family || 'unknownauthor' + const year = entry.issued?.['date-parts']?.[0]?.[0] || '9999' + + const titleWords = entry.title?.split(' ') || [] + const firstSignificantWord = + titleWords.find((word) => !stopwordSet.has(word.toLowerCase())) || + 'unknowntitle' + + return `${author}${year}${firstSignificantWord}` + .replace(/\s+/g, '') + .toLowerCase() +} diff --git a/src/bibliography/ui/BibliographyEntryForm.tsx b/src/bibliography/ui/BibliographyEntryForm.tsx index 1d50a1210..2aabbef64 100644 --- a/src/bibliography/ui/BibliographyEntryForm.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.tsx @@ -10,6 +10,7 @@ import Spinner from 'common/Spinner' import BibliographyEntry, { CslData, } from 'bibliography/domain/BibliographyEntry' +import { generateIds } from 'bibliography/domain/GenerateIds' import './BibliographyEntryForm.css' @@ -40,6 +41,7 @@ interface State { value: string cslData: ReadonlyArray | null loading: boolean + customId: string isInvalid: boolean } @@ -56,6 +58,7 @@ export default class BibliographyEntryForm extends Component { cslData: [props.value.toCslData()], value: JSON.stringify(props.value.toCslData(), null, 2), loading: false, + customId: '', isInvalid: false, } : { @@ -63,6 +66,7 @@ export default class BibliographyEntryForm extends Component { cslData: null, value: '', loading: false, + customId: '', isInvalid: false, } this.promise = Promise.resolve() @@ -100,42 +104,64 @@ export default class BibliographyEntryForm extends Component { load = (value: string): Promise => { this.promise.cancel() - return new Promise((resolve, reject) => { - Cite.async(value).then(resolve).catch(reject) - }) - .then((cite: Cite) => { - this.setState({ - ...this.state, - citation: cite.format('bibliography', { - format: 'html', - template: 'citation-apa', - lang: 'de-DE', - }), - cslData: cite.get({ - format: 'real', - type: 'json', - style: 'csl', - }), - loading: false, - }) + + const handleSuccess = (cite: Cite): void => { + const cslData = cite.get({ + format: 'real', + type: 'json', + style: 'csl', }) - .catch(() => { - this.setState({ - ...this.state, - citation: '', - cslData: null, - loading: false, - isInvalid: true, - }) + + const customId = generateIds(cslData[0]) + + this.setState({ + ...this.state, + citation: this.formatCitation(cite), + cslData, + customId, + loading: false, }) + } + + const handleError = (): void => { + this.setState({ + ...this.state, + citation: '', + cslData: null, + loading: false, + isInvalid: true, + }) + } + + return new Promise((resolve, reject) => { + Cite.async(value) + .then((cite: Cite) => { + handleSuccess(cite) + resolve() + }) + .catch(() => { + handleError() + reject() + }) + }) + } + + formatCitation = (cite: Cite): string => { + return cite.format('bibliography', { + format: 'html', + template: 'citation-apa', + lang: 'de-DE', + }) } handleSubmit = (event: React.FormEvent): void => { event.preventDefault() - const entry = new BibliographyEntry( - this.state.cslData && this.state.cslData[0] - ) - this.props.onSubmit(entry) + if (this.state.cslData && this.state.cslData[0]) { + const entryData = { ...this.state.cslData[0] } + ;(entryData as { [key: string]: any }).id = this.state.customId + const entry = new BibliographyEntry(entryData) + this.props.onSubmit(entry) + } } render(): JSX.Element { diff --git a/yarn.lock b/yarn.lock index 8b606aafa..bd2a2ff27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13497,6 +13497,11 @@ stdout-stream@^1.4.0: dependencies: readable-stream "^2.0.1" +stopwords@^0.0.9: + version "0.0.9" + resolved "https://registry.yarnpkg.com/stopwords/-/stopwords-0.0.9.tgz#d2ceb30c778b3b12896d476a0ca5e9946141b5e3" + integrity sha512-2OF7/9f5buXrBB8De5ZAC9+g5nIw9SRx9ZsL3Qpz6znSfPEihOmaMnwJxXehynZ9AfRNroHIKEIcsEmRywVXVQ== + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b" From 5a25cc0026368b65e6f483befd64ad35b94922f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Jim=C3=A9nez?= Date: Mon, 6 Jan 2025 22:40:50 +0000 Subject: [PATCH 2/5] Fix routes, fix id generation --- src/bibliography/ui/BibliographyEditor.tsx | 10 ++++++++-- src/bibliography/ui/BibliographyEntryForm.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bibliography/ui/BibliographyEditor.tsx b/src/bibliography/ui/BibliographyEditor.tsx index 406eeef44..0bf8b1bce 100644 --- a/src/bibliography/ui/BibliographyEditor.tsx +++ b/src/bibliography/ui/BibliographyEditor.tsx @@ -62,9 +62,15 @@ export default withData< BibliographyEntry >( BibliographyEditor, - (props) => props.bibliographyService.find(props.match.params['id']), + (props) => { + const decodedId = decodeURIComponent(props.match.params['id']) + return props.bibliographyService.find(decodedId) + }, { - watch: (props) => [props.create, props.match.params['id']], + watch: (props) => [ + props.create, + decodeURIComponent(props.match.params['id']), + ], filter: (props) => !props.create, defaultData: () => template, } diff --git a/src/bibliography/ui/BibliographyEntryForm.tsx b/src/bibliography/ui/BibliographyEntryForm.tsx index 2aabbef64..d3e25270d 100644 --- a/src/bibliography/ui/BibliographyEntryForm.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.tsx @@ -157,8 +157,10 @@ export default class BibliographyEntryForm extends Component { handleSubmit = (event: React.FormEvent): void => { event.preventDefault() if (this.state.cslData && this.state.cslData[0]) { - const entryData = { ...this.state.cslData[0] } - ;(entryData as { [key: string]: any }).id = this.state.customId + const entryData = { + ...this.state.cslData[0], + id: this.state.customId, + } as CslData & { id: string } const entry = new BibliographyEntry(entryData) this.props.onSubmit(entry) } From cce4a857e404ffa3f9fc6f23ac2753eb6c2dd898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Jim=C3=A9nez?= Date: Tue, 7 Jan 2025 14:18:53 +0000 Subject: [PATCH 3/5] Add custom Id tests --- src/bibliography/domain/BibliographyEntry.ts | 66 +++++++------- .../ui/BibliographyEntryForm.test.tsx | 90 +++++++++++++++---- src/bibliography/ui/BibliographyEntryForm.tsx | 85 +++++++++--------- 3 files changed, 148 insertions(+), 93 deletions(-) diff --git a/src/bibliography/domain/BibliographyEntry.ts b/src/bibliography/domain/BibliographyEntry.ts index c7e11fd1f..bb4df293b 100644 --- a/src/bibliography/domain/BibliographyEntry.ts +++ b/src/bibliography/domain/BibliographyEntry.ts @@ -28,12 +28,15 @@ export default class BibliographyEntry { constructor(cslData?: CslData | null | undefined) { this.cslData = cslData ? produce(cslData, (draft) => { - _.keys(draft) + // Remove properties starting with '_' + Object.keys(draft) .filter((key) => key.startsWith('_')) - .forEach(_.partial(_.unset, draft)) + .forEach((key) => delete draft[key]) + + // Normalize author data if (draft.author) { - draft.author = draft.author.map( - _.partialRight(_.pick, authorProperties) + draft.author = draft.author.map((author) => + _.pick(author, authorProperties) ) } }) @@ -41,75 +44,72 @@ export default class BibliographyEntry { } get id(): string { - return _.get(this.cslData, 'id', '') + return this.cslData.id || '' } get primaryAuthor(): string { - return _.head(this.authors) || '' + return this.authors[0] || '' } get authors(): string[] { - return _.get(this.cslData, 'author', []).map(getName) + return (this.cslData.author || []).map(getName) } get year(): string { - const start = _.get(this.cslData, 'issued.date-parts.0.0', '') - const end = _.get(this.cslData, 'issued.date-parts.1.0', '') + const dates = _.get(this.cslData, 'issued.date-parts', []) + const start = dates[0]?.[0] || '' + const end = dates[1]?.[0] || '' return end ? `${start}–${end}` : String(start) } get title(): string { - return _.get(this.cslData, 'title', '') + return this.cslData.title || '' } get shortContainerTitle(): string { - return _.get(this.cslData, 'container-title-short', '') + return this.cslData['container-title-short'] || '' } get shortTitle(): string { - return _.get(this.cslData, 'title-short', '') + return this.cslData['title-short'] || '' } get collectionNumber(): string { - return _.get(this.cslData, 'collection-number', '') + return this.cslData['collection-number'] || '' } get volume(): string { - return _.get(this.cslData, 'volume', '') + return this.cslData.volume || '' } get link(): string { - const url = _.get(this.cslData, 'URL', '') - const doi = _.get(this.cslData, 'DOI', '') - return url || (doi ? `https://doi.org/${doi}` : '') + return ( + this.cslData.URL || + (this.cslData.DOI ? `https://doi.org/${this.cslData.DOI}` : '') + ) } get authorYearTitle(): string { return `${this.primaryAuthor} ${this.year} ${this.title}` } - get abberviationContainer(): string | undefined { - const containerTitleShort = this.shortContainerTitle - const collectionNumber = this.collectionNumber - ? ` ${this.collectionNumber}` - : '' - return containerTitleShort - ? `${containerTitleShort}${collectionNumber}` - : undefined + get abbreviationContainer(): string | undefined { + const container = this.shortContainerTitle + const number = this.collectionNumber ? ` ${this.collectionNumber}` : '' + return container ? `${container}${number}` : undefined } get abbreviationTitle(): string | undefined { - const { shortTitle } = this - const volume = this.volume ? ` ${this.volume}` : '' - return shortTitle ? `${shortTitle}${volume}` : undefined + const title = this.shortTitle + const vol = this.volume ? ` ${this.volume}` : '' + return title ? `${title}${vol}` : undefined } get abbreviations(): string | undefined { - const { abberviationContainer, abbreviationTitle } = this - if (abberviationContainer && abbreviationTitle) { - return `${abberviationContainer} = ${abbreviationTitle}` - } - return abberviationContainer ?? abbreviationTitle ?? undefined + const container = this.abbreviationContainer + const title = this.abbreviationTitle + if (container && title) return `${container} = ${title}` + return container || title } get label(): string { diff --git a/src/bibliography/ui/BibliographyEntryForm.test.tsx b/src/bibliography/ui/BibliographyEntryForm.test.tsx index 1dcde8a13..c42891b3a 100644 --- a/src/bibliography/ui/BibliographyEntryForm.test.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.test.tsx @@ -1,34 +1,88 @@ import React from 'react' -import { render, screen } from '@testing-library/react' -import _ from 'lodash' - +import { render, screen, waitFor } from '@testing-library/react' import { changeValueByLabel, clickNth } from 'test-support/utils' import BibliographyEntryForm from './BibliographyEntryForm' import { bibliographyEntryFactory } from 'test-support/bibliography-fixtures' import BibliographyEntry from 'bibliography/domain/BibliographyEntry' -let json: string -let entry: BibliographyEntry -let onSubmit: () => void +let mockJson: string +let mockEntry: BibliographyEntry +let onSubmitMock: jest.Mock beforeEach(() => { - entry = bibliographyEntryFactory.build() - json = JSON.stringify(entry.toCslData(), null, 2) - onSubmit = jest.fn() + mockEntry = bibliographyEntryFactory.build() + mockJson = JSON.stringify(mockEntry.toCslData(), null, 2) + onSubmitMock = jest.fn() +}) + +test('Form updates and submits entry with correct data', async () => { + render() + changeValueByLabel(screen, 'Data', mockJson) + await screen.findByText(new RegExp(`\\(${mockEntry.year}\\)`)) + clickNth(screen, 'Save', 0) + + expect(onSubmitMock).toHaveBeenCalledWith(mockEntry) }) -test(`Changing document calls onChange with updated value.`, async () => { - render() - changeValueByLabel(screen, 'Data', json) - await screen.findByText(new RegExp(_.escapeRegExp(`(${entry.year})`))) +test('Displays CSL-JSON input correctly', async () => { + render() + const textarea = screen.getByLabelText('Data') as HTMLTextAreaElement + await waitFor(() => { + expect(textarea.value.replace(/\s/g, '')).toContain( + JSON.stringify(mockEntry.toCslData()).replace(/\s/g, '') + ) + }) +}) + +test('Applies custom ID when no ID exists', async () => { + const entryWithoutId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: undefined }), + }) + const jsonWithoutId = JSON.stringify(entryWithoutId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithoutId) + + // Wait for debounce to complete before submitting + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } + ) + clickNth(screen, 'Save', 0) - expect(onSubmit).toHaveBeenCalledWith(entry) + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).not.toBeUndefined() + expect(submittedEntry.id).not.toMatch(/^temp_id/) + }) }) -test(`Shows value as CSL-JSON.`, async () => { - render() - await screen.findByDisplayValue( - new RegExp(_.escapeRegExp(json).replace(/\s+/g, '\\s*')) +test('Preserves existing ID', async () => { + const entryWithValidId = bibliographyEntryFactory.build({ + toCslData: () => ({ ...mockEntry.toCslData(), id: 'validId123' }), + }) + const jsonWithValidId = JSON.stringify(entryWithValidId.toCslData(), null, 2) + + render() + changeValueByLabel(screen, 'Data', jsonWithValidId) + + // Wait for debounce to complete before submitting + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } ) + + clickNth(screen, 'Save', 0) + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled() + + const submittedEntry = onSubmitMock.mock.calls[0][0] + + expect(submittedEntry.id).toEqual('validId123') + }) }) diff --git a/src/bibliography/ui/BibliographyEntryForm.tsx b/src/bibliography/ui/BibliographyEntryForm.tsx index d3e25270d..0de6615b7 100644 --- a/src/bibliography/ui/BibliographyEntryForm.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.tsx @@ -14,21 +14,19 @@ import { generateIds } from 'bibliography/domain/GenerateIds' import './BibliographyEntryForm.css' -function BibliographyHelp() { - return ( -

- You can enter a DOI, CSL-JSON, BibTeX, or any{' '} - - supported input format - - . BibTeX can be generated with{' '} - - BibTeX Online Editor - - . -

- ) -} +const BibliographyHelp = () => ( +

+ You can enter a DOI, CSL-JSON, BibTeX, or any{' '} + + supported input format + + . BibTeX can be generated with{' '} + + BibTeX Online Editor + + . +

+) interface Props { value?: BibliographyEntry | null @@ -46,17 +44,24 @@ interface State { } export default class BibliographyEntryForm extends Component { - static defaultProps: { value: null; disabled: false } + static defaultProps = { value: null, disabled: false } + private promise: Promise private doLoad: (value: string) => Promise | undefined constructor(props: Props) { super(props) - this.state = props.value + this.state = this.getInitialState(props.value) + this.promise = Promise.resolve() + this.doLoad = _.debounce(this.load, 500, { leading: false, trailing: true }) + } + + private getInitialState(value?: BibliographyEntry | null): State { + return value ? { - citation: props.value.toHtml(), - cslData: [props.value.toCslData()], - value: JSON.stringify(props.value.toCslData(), null, 2), + citation: value.toHtml(), + cslData: [value.toCslData()], + value: JSON.stringify(value.toCslData(), null, 2), loading: false, customId: '', isInvalid: false, @@ -69,18 +74,13 @@ export default class BibliographyEntryForm extends Component { customId: '', isInvalid: false, } - this.promise = Promise.resolve() - this.doLoad = _.debounce(this.load, 500, { - leading: false, - trailing: true, - }) } - get isValid(): boolean { + private get isValid(): boolean { return _.isArray(this.state.cslData) && this.state.cslData.length === 1 } - get isInvalid(): boolean { + private get isInvalid(): boolean { return ( !this.state.loading && !_.isEmpty(this.state.value) && @@ -88,11 +88,11 @@ export default class BibliographyEntryForm extends Component { ) } - get isDisabled(): boolean { + private get isDisabled(): boolean { return !this.isValid || this.props.disabled } - handleChange = (event: React.ChangeEvent): void => { + private handleChange = (event: React.ChangeEvent): void => { this.setState({ ...this.state, value: event.target.value, @@ -102,16 +102,11 @@ export default class BibliographyEntryForm extends Component { this.promise = this.doLoad(event.target.value) || this.promise } - load = (value: string): Promise => { + private load = (value: string): Promise => { this.promise.cancel() const handleSuccess = (cite: Cite): void => { - const cslData = cite.get({ - format: 'real', - type: 'json', - style: 'csl', - }) - + const cslData = cite.get({ format: 'real', type: 'json', style: 'csl' }) const customId = generateIds(cslData[0]) this.setState({ @@ -146,7 +141,7 @@ export default class BibliographyEntryForm extends Component { }) } - formatCitation = (cite: Cite): string => { + private formatCitation = (cite: Cite): string => { return cite.format('bibliography', { format: 'html', template: 'citation-apa', @@ -154,18 +149,24 @@ export default class BibliographyEntryForm extends Component { }) } - handleSubmit = (event: React.FormEvent): void => { + private handleSubmit = (event: React.FormEvent): void => { event.preventDefault() if (this.state.cslData && this.state.cslData[0]) { - const entryData = { - ...this.state.cslData[0], - id: this.state.customId, - } as CslData & { id: string } + const entryData = this.applyCustomIdIfNeeded(this.state.cslData[0]) const entry = new BibliographyEntry(entryData) this.props.onSubmit(entry) } } + private applyCustomIdIfNeeded = ( + cslData: CslData + ): CslData & { id: string } => { + const id = cslData.id?.trim() + return !id || id.startsWith('temp_id') + ? { ...cslData, id: this.state.customId } + : { ...cslData, id } + } + render(): JSX.Element { const parsed = new Parser().parse(this.state.citation) return ( From 233a6cb6eabde78bb320327bcafeb8d0f6439987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Jim=C3=A9nez?= Date: Tue, 7 Jan 2025 14:24:57 +0000 Subject: [PATCH 4/5] Fix duplication issues to satisfy CodeClimate --- src/bibliography/domain/GenerateIds.test.ts | 83 ++++++++++--------- .../ui/BibliographyEntryForm.test.tsx | 19 ++--- 2 files changed, 52 insertions(+), 50 deletions(-) diff --git a/src/bibliography/domain/GenerateIds.test.ts b/src/bibliography/domain/GenerateIds.test.ts index 5a22d82a9..30c02ee0c 100644 --- a/src/bibliography/domain/GenerateIds.test.ts +++ b/src/bibliography/domain/GenerateIds.test.ts @@ -9,55 +9,58 @@ const testEntry: CslData = { } describe('generateIds', () => { + const testIdGeneration = ( + name: string, + modifiedEntry: Partial, + expectedId: string + ) => { + test(name, () => { + const result = generateIds({ ...testEntry, ...modifiedEntry }) + expect(result).toBe(expectedId) + }) + } + test('basic ID generation', () => { const result = generateIds(testEntry) expect(result).toBe('doe2023quick') }) - test('ID generation with missing author', () => { - const entryWithNoAuthor = { ...testEntry, author: undefined } - const result = generateIds(entryWithNoAuthor) - expect(result).toBe('unknownauthor2023quick') - }) - - test('ID generation with missing year', () => { - const entryWithNoYear = { ...testEntry, issued: undefined } - const result = generateIds(entryWithNoYear) - expect(result).toBe('doe9999quick') - }) - - test('ID generation with missing title', () => { - const entryWithNoTitle = { ...testEntry, title: undefined } - const result = generateIds(entryWithNoTitle) - expect(result).toBe('doe2023unknowntitle') - }) - - test('ID generation with all significant words as stop words', () => { - const entryWithOnlyStopWords = { - ...testEntry, - title: 'The Of And But Or Nor For', - } - const result = generateIds(entryWithOnlyStopWords) - expect(result).toBe('doe2023unknowntitle') - }) + testIdGeneration( + 'ID generation with missing author', + { author: undefined }, + 'unknownauthor2023quick' + ) + testIdGeneration( + 'ID generation with missing year', + { issued: undefined }, + 'doe9999quick' + ) + testIdGeneration( + 'ID generation with missing title', + { title: undefined }, + 'doe2023unknowntitle' + ) + testIdGeneration( + 'ID generation with all significant words as stop words', + { title: 'The Of And But Or Nor For' }, + 'doe2023unknowntitle' + ) - test('ID generation with different language (German)', () => { - const germanEntry = { - ...testEntry, + testIdGeneration( + 'ID generation with different language (German)', + { language: 'de', title: 'Der Schnelle Braune Fuchs Springt Über den Faulen Hund', - } - const result = generateIds(germanEntry) - expect(result).toBe('doe2023schnelle') - }) + }, + 'doe2023schnelle' + ) - test('ID generation with language not supported', () => { - const unsupportedLangEntry = { - ...testEntry, + testIdGeneration( + 'ID generation with language not supported', + { language: 'ru', title: 'Быстрый Коричневый Лис Перепрыгивает Через Ленивую Собаку', - } - const result = generateIds(unsupportedLangEntry) - expect(result).toBe('doe2023быстрый') - }) + }, + 'doe2023быстрый' + ) }) diff --git a/src/bibliography/ui/BibliographyEntryForm.test.tsx b/src/bibliography/ui/BibliographyEntryForm.test.tsx index c42891b3a..4189557f0 100644 --- a/src/bibliography/ui/BibliographyEntryForm.test.tsx +++ b/src/bibliography/ui/BibliographyEntryForm.test.tsx @@ -15,6 +15,13 @@ beforeEach(() => { onSubmitMock = jest.fn() }) +const waitForSaveButtonToBeEnabled = async () => { + await waitFor( + () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), + { timeout: 1000 } + ) +} + test('Form updates and submits entry with correct data', async () => { render() changeValueByLabel(screen, 'Data', mockJson) @@ -43,11 +50,7 @@ test('Applies custom ID when no ID exists', async () => { render() changeValueByLabel(screen, 'Data', jsonWithoutId) - // Wait for debounce to complete before submitting - await waitFor( - () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), - { timeout: 1000 } - ) + await waitForSaveButtonToBeEnabled() clickNth(screen, 'Save', 0) @@ -70,11 +73,7 @@ test('Preserves existing ID', async () => { render() changeValueByLabel(screen, 'Data', jsonWithValidId) - // Wait for debounce to complete before submitting - await waitFor( - () => expect(screen.getByRole('button', { name: /Save/i })).toBeEnabled(), - { timeout: 1000 } - ) + await waitForSaveButtonToBeEnabled() clickNth(screen, 'Save', 0) From f802e2aa062fb2dce0e3c242fda4a44fbda659ef Mon Sep 17 00:00:00 2001 From: Ilya Khait Date: Tue, 7 Jan 2025 18:47:47 +0000 Subject: [PATCH 5/5] Add minor fixes --- src/bibliography/domain/BibliographyEntry.ts | 4 +--- src/bibliography/domain/GenerateIds.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/bibliography/domain/BibliographyEntry.ts b/src/bibliography/domain/BibliographyEntry.ts index bb4df293b..1913d3cfe 100644 --- a/src/bibliography/domain/BibliographyEntry.ts +++ b/src/bibliography/domain/BibliographyEntry.ts @@ -20,6 +20,7 @@ function getName(author: unknown): string { return particle ? `${particle} ${family}` : family } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type CslData = { readonly [key: string]: any } export default class BibliographyEntry { readonly [immerable] = true @@ -28,12 +29,9 @@ export default class BibliographyEntry { constructor(cslData?: CslData | null | undefined) { this.cslData = cslData ? produce(cslData, (draft) => { - // Remove properties starting with '_' Object.keys(draft) .filter((key) => key.startsWith('_')) .forEach((key) => delete draft[key]) - - // Normalize author data if (draft.author) { draft.author = draft.author.map((author) => _.pick(author, authorProperties) diff --git a/src/bibliography/domain/GenerateIds.test.ts b/src/bibliography/domain/GenerateIds.test.ts index 30c02ee0c..11d7958d2 100644 --- a/src/bibliography/domain/GenerateIds.test.ts +++ b/src/bibliography/domain/GenerateIds.test.ts @@ -59,8 +59,8 @@ describe('generateIds', () => { 'ID generation with language not supported', { language: 'ru', - title: 'Быстрый Коричневый Лис Перепрыгивает Через Ленивую Собаку', + title: 'Экс-граф? Плюш изъят. Бьём чуждый цен хвощ!', }, - 'doe2023быстрый' + 'doe2023экс-граф?' ) })