├── tests ├── unit │ ├── utils │ │ ├── testMocks.js │ │ └── authorColor.test.js │ ├── Publication.test.js │ ├── components │ │ ├── PublicationComponentSearch.test.js │ │ ├── HeaderPanel.test.js │ │ ├── NetworkVisComponentHover.test.js │ │ ├── PublicationTag.test.js │ │ ├── NetworkPerformanceMonitor.test.js │ │ ├── NetworkControls.test.js │ │ ├── PublicationDescription.test.js │ │ └── PublicationListComponent.test.js │ ├── features │ │ ├── keyboard-navigation-improved.test.js │ │ └── author-modal-pagination-logic.test.js │ ├── bugs │ │ ├── author-year-merge-infinity.test.js │ │ └── quickaccessbar-null-component.test.js │ ├── integration │ │ └── author-modal-workflow.test.js │ ├── Author.test.js │ └── services │ │ └── SuggestionService.test.js ├── setup.js ├── performance │ └── utils.js └── utils │ └── d3-mocks.js ├── .prettierignore ├── pure_suggest.png ├── test ├── authors.md ├── anomalies.md ├── dois.md ├── abstracts.md ├── titles.md ├── sessions │ ├── 11-selected.text_visual_story.json │ └── 40-selected.infovis_perception_cognition.json └── missing_data.md ├── misc └── open_citations_token.txt ├── public ├── favicon.ico ├── sitemap.xml └── robots.txt ├── src ├── jsconfig.json ├── components │ ├── basic │ │ ├── InlineIcon.vue │ │ ├── CompactSwitch.vue │ │ ├── ErrorToast.vue │ │ ├── CompactButton.vue │ │ ├── InfoDialog.vue │ │ └── ConfirmDialog.vue │ ├── HeaderExternalLinks.vue │ ├── PublicationComponentSearch.vue │ ├── PublicationTag.vue │ ├── AuthorGlyph.vue │ ├── modal │ │ ├── ShareSessionModalDialog.vue │ │ ├── ModalDialog.vue │ │ ├── KeyboardControlsModalDialog.vue │ │ └── ExcludedModalDialog.vue │ ├── QuickAccessBar.vue │ ├── NetworkPerformanceMonitor.vue │ ├── NetworkHeader.vue │ ├── SessionMenuComponent.vue │ └── HeaderPanel.vue ├── assets │ ├── _shared.scss │ └── bulma-color-overrides.css ├── utils │ ├── authorColor.js │ ├── filterUtils.js │ ├── performance.js │ └── network │ │ ├── yearLabels.js │ │ ├── links.js │ │ ├── authorNodes.js │ │ ├── forces.js │ │ └── keywordNodes.js ├── stores │ ├── queue.js │ ├── modal.js │ ├── author.js │ └── interface.js ├── constants │ └── config.js ├── lib │ ├── FpsTracker.js │ ├── Util.js │ └── Cache.js ├── main.js ├── core │ ├── PublicationSearch.js │ └── Filter.js └── composables │ └── useModalManager.js ├── vue.config.js ├── deploy.ps1 ├── .prettierrc ├── .gitignore ├── vitest.performance.config.js ├── .github └── workflows │ ├── node.js.yml │ └── npm-publish-github-packages.yml ├── LICENSE ├── vite.config.mjs ├── vite.config.perf.mjs ├── .claude └── commands │ ├── cleanup-tests.md │ ├── cleanup-unused.md │ ├── address-issue.md │ ├── find-bug.md │ └── cleanup-comments.md ├── package.json └── README.md /tests/unit/utils/testMocks.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | .git/ 5 | *.min.js 6 | *.min.css -------------------------------------------------------------------------------- /pure_suggest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabian-beck/pure-suggest/HEAD/pure_suggest.png -------------------------------------------------------------------------------- /test/authors.md: -------------------------------------------------------------------------------- 1 | # Publications with Challenging Author Names 2 | 3 | - 10.1145/3387165 4 | -------------------------------------------------------------------------------- /misc/open_citations_token.txt: -------------------------------------------------------------------------------- 1 | OpenCitations Access Token 2 | aa9da96d-3c7b-49c1-a2d8-1c2d01ae10a5 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabian-beck/pure-suggest/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /test/anomalies.md: -------------------------------------------------------------------------------- 1 | # Publications with Other Anomalies 2 | 3 | ## Wrong Year 4 | 5 | - 10.1007/978-1-4899-3324-9 6 | -------------------------------------------------------------------------------- /test/dois.md: -------------------------------------------------------------------------------- 1 | # Publications with Special DOIs 2 | 3 | - 10.1002/(SICI)1099-1727(199821)14:1<3::AID-SDR140>3.0.CO;2-K 4 | -------------------------------------------------------------------------------- /src/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | module.exports = { 3 | publicPath: process.env.NODE_ENV === 'production' ? '/pure-suggest/' : '/' 4 | } 5 | -------------------------------------------------------------------------------- /deploy.ps1: -------------------------------------------------------------------------------- 1 | npm run build 2 | cd dist 3 | git init 4 | git add -A 5 | git commit -m 'deploy' 6 | git push -f https://github.com/fabian-beck/pure-suggest.git main:gh-pages 7 | cd .. -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "none", 6 | "printWidth": 100, 7 | "vueIndentScriptAndStyle": false 8 | } 9 | -------------------------------------------------------------------------------- /test/abstracts.md: -------------------------------------------------------------------------------- 1 | # Publications with Abstracts 2 | 3 | ## Challenging Abstracts 4 | 5 | 10.1108/17506160910960568 6 | 10.1073/pnas.0507655102 7 | 10.1186/s12885-021-07818-4 8 | 10.3389/fmed.2021.740710 9 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://fabian-beck.github.io/pure-suggest/ 5 | 2024-09-09 6 | monthly 7 | 1.0 8 | 9 | -------------------------------------------------------------------------------- /test/titles.md: -------------------------------------------------------------------------------- 1 | # Publications with Challenging Titles 2 | 3 | ## Special Characters 4 | 5 | - 10.1145/3472749.3474731 6 | - 10.1080/25742442.2019.1637082 7 | - 10.1109/vast.2009.5333248 8 | 9 | ## HMTL Tags 10 | 11 | - 10.1145/2807442.2807446 12 | - 10.1109/tvcg.2017.2744019 13 | - 10.1111/cgf.13206 14 | - 10.1111/j.2041-210x.2011.00179.x 15 | - 10.1080/25742442.2019.1637082 16 | 17 | ## Dashes 18 | 19 | - 10.1145/3121113.3121221 20 | 21 | ## Challenges in/with Subtitle 22 | 23 | - 10.1111/cgf.12804 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | .claude/settings.local.json 23 | 24 | # Test results 25 | test-results/ 26 | 27 | # Development tools output 28 | tools/output/ 29 | 30 | # Auto-generated component types 31 | components.d.ts 32 | 33 | # Playwright screenshots 34 | .playwright-mcp/ 35 | -------------------------------------------------------------------------------- /src/components/basic/InlineIcon.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 22 | 23 | 29 | -------------------------------------------------------------------------------- /src/components/HeaderExternalLinks.vue: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/assets/_shared.scss: -------------------------------------------------------------------------------- 1 | @mixin scrollable-list { 2 | max-height: 100%; 3 | overflow-y: auto; 4 | @include inset-shadow; 5 | } 6 | 7 | @mixin inset-shadow { 8 | box-shadow: 1px 2px 3px 0px inset rgba(0, 0, 0, 0.2); 9 | } 10 | 11 | @mixin light-shadow { 12 | box-shadow: 1px 1px 5px rgba($color: #000000, $alpha: 0.2); 13 | } 14 | 15 | @mixin light-shadow-svg { 16 | filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.2)); 17 | } 18 | 19 | @mixin comment { 20 | .comment { 21 | margin: 0; 22 | padding: 0.5rem; 23 | font-size: 0.8rem; 24 | color: #888; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/basic/CompactSwitch.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | 19 | 27 | -------------------------------------------------------------------------------- /test/sessions/11-selected.text_visual_story.json: -------------------------------------------------------------------------------- 1 | { 2 | "selected": [ 3 | "10.1109/pacificvis53943.2022.00023", 4 | "10.1111/cgf.14309", 5 | "10.1111/cgf.13719", 6 | "10.1109/tvcg.2010.179", 7 | "10.1111/cgf.13195", 8 | "10.1145/3377325.3377517", 9 | "10.1109/tvcg.2013.119", 10 | "10.1145/3447992", 11 | "10.1145/2556288.2557241", 12 | "10.1145/3411764.3445344", 13 | "10.1109/tvcg.2021.3114802" 14 | ], 15 | "excluded": ["10.1109/tvcg.2020.3030358", "10.1145/3411764.3445354", "10.1145/3172944.3173007"], 16 | "boost": "link, text, visual, stor, integr, evalu, geo, data, refer, underst, narra, chart, guid, interact" 17 | } 18 | -------------------------------------------------------------------------------- /vitest.performance.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@': resolve(__dirname, 'src') 10 | } 11 | }, 12 | test: { 13 | environment: 'happy-dom', 14 | setupFiles: ['tests/setup.js'], 15 | include: ['tests/performance/**/*.{test,spec,perf}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 16 | reporter: ['verbose', 'json'], 17 | outputFile: { 18 | json: './test-results/performance-results.json' 19 | }, 20 | testTimeout: 30000, 21 | globals: true 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /src/utils/authorColor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculate author color based on score and current author store settings 3 | * @param {number} score - The author's score 4 | * @param {Object} authorStore - The author store with settings 5 | * @returns {string} HSL color string 6 | */ 7 | export function calculateAuthorColor(score, authorStore) { 8 | let adjustedScore = score 9 | 10 | // Apply the same adjustments as in AuthorGlyph.vue 11 | if (!authorStore.isAuthorScoreEnabled) { 12 | adjustedScore = adjustedScore * 20 13 | } 14 | if (!authorStore.isFirstAuthorBoostEnabled) { 15 | adjustedScore = adjustedScore * 1.5 16 | } 17 | if (!authorStore.isAuthorNewBoostEnabled) { 18 | adjustedScore = adjustedScore * 1.5 19 | } 20 | 21 | return `hsl(0, 0%, ${Math.round(Math.max(60 - adjustedScore / 3, 0))}%)` 22 | } 23 | -------------------------------------------------------------------------------- /src/stores/queue.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useQueueStore = defineStore('queue', { 4 | state: () => { 5 | return { 6 | selectedQueue: [], 7 | excludedQueue: [] 8 | } 9 | }, 10 | getters: { 11 | isUpdatable: (state) => state.selectedQueue.length > 0 || state.excludedQueue.length > 0, 12 | isQueuingForSelected: (state) => (doi) => state.selectedQueue.includes(doi), 13 | isQueuingForExcluded: (state) => (doi) => state.excludedQueue.includes(doi) 14 | }, 15 | actions: { 16 | removeFromQueues(doi) { 17 | this.selectedQueue = this.selectedQueue.filter((seletedDoi) => doi != seletedDoi) 18 | this.excludedQueue = this.excludedQueue.filter((excludedDoi) => doi != excludedDoi) 19 | }, 20 | 21 | clear() { 22 | this.excludedQueue = [] 23 | this.selectedQueue = [] 24 | } 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/basic/ErrorToast.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 38 | -------------------------------------------------------------------------------- /test/missing_data.md: -------------------------------------------------------------------------------- 1 | # Publications with Missing Data 2 | 3 | ## Year 4 | 5 | 10.1109/iv.2006.108 6 | 10.1109/infvis.1995.528686 7 | 10.1109/infvis.2005.1532122 8 | 10.1109/infvis.2004.77 9 | 10.1109/vl.1996.545307 10 | 10.1109/visual.1990.146402 11 | 10.1007/978-3-540-71701-0_9 12 | 10.1007/978-3-540-78946-8_11 13 | 10.1007/3-540-73679-4_9 14 | 10.4018/978-1-4666-2521-1.ch015 15 | 10.1007/978-0-387-36503-9_30 16 | 10.1007/1-84628-084-2_8 17 | 10.7717/peerjcs.87/fig-4 18 | 10.1007/978-3-540-93964-1_1 19 | 20 | ## Title 21 | 22 | 10.1023/a:1005647328460 23 | 10.1023/a:1012801612483 24 | 10.1023/a:1007617005950 25 | 26 | ## Author 27 | 28 | 10.1016/b978-1-84334-572-5.50020-2 29 | 10.1017/9781108671682.015 30 | 10.1017/9781108671682.012 31 | 10.1002/9781118950951.ch14 32 | 10.1017/9781108614528.003 33 | 34 | ## Year, author, and venue 35 | 36 | 10.7717/peerjcs.87/fig-4 37 | 38 | ## No data 39 | 40 | 10.2312:vmv.20211367 - typo in DOI (':' -> '/'/) 41 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [12.x, 14.x, 16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /src/constants/config.js: -------------------------------------------------------------------------------- 1 | // Application-wide configuration constants 2 | 3 | // API Configuration 4 | export const API_ENDPOINTS = { 5 | PUBLICATIONS: 'https://pure-publications-cw3de4q5va-ew.a.run.app/', 6 | CROSSREF: 'https://api.crossref.org/works', 7 | OPENALEX: 'https://api.openalex.org/works', 8 | GOOGLE_SCHOLAR: 'https://scholar.google.de/scholar' 9 | } 10 | 11 | export const API_PARAMS = { 12 | NO_CACHE_PARAM: '&noCache=true', 13 | CROSSREF_EMAIL: 'fabian.beck@uni-bamberg.de', 14 | CROSSREF_FILTER: 'has-references:true', 15 | CROSSREF_SORT: 'relevance', 16 | OPENALEX_EMAIL: 'fabian.beck@uni-bamberg.de' 17 | } 18 | 19 | // Pagination Configuration 20 | export const PAGINATION = { 21 | LOAD_MORE_INCREMENT: 100, 22 | INITIAL_SUGGESTIONS_COUNT: 100 23 | } 24 | 25 | // Scoring Configuration 26 | export const SCORING = { 27 | DEFAULT_BOOST_FACTOR: 1, 28 | BOOST_MULTIPLIER: 2, 29 | FIRST_AUTHOR_BOOST: 2, 30 | NEW_PUBLICATION_BOOST: 2 31 | } 32 | 33 | // Time Configuration 34 | export const CURRENT_YEAR = new Date().getFullYear() 35 | -------------------------------------------------------------------------------- /src/components/basic/CompactButton.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 43 | 44 | 53 | -------------------------------------------------------------------------------- /src/components/PublicationComponentSearch.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 40 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish-github-packages.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 16 18 | - run: npm ci 19 | - run: npm test 20 | 21 | publish-gpr: 22 | needs: build 23 | runs-on: ubuntu-latest 24 | permissions: 25 | contents: read 26 | packages: write 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: actions/setup-node@v2 30 | with: 31 | node-version: 16 32 | registry-url: https://npm.pkg.github.com/ 33 | - run: npm ci 34 | - run: npm publish 35 | env: 36 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 37 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # Robots.txt for PUREsuggest - Citation-based literature search tool 2 | # https://fabian-beck.github.io/pure-suggest/ 3 | 4 | User-agent: * 5 | 6 | # Allow main application files 7 | Allow: / 8 | Allow: /index.html 9 | Allow: /favicon.ico 10 | Allow: /pure_suggest.png 11 | Allow: /assets/ 12 | 13 | # Disallow development and build directories 14 | # (Most won't be deployed to GitHub Pages anyway, but explicit for clarity) 15 | Disallow: /node_modules/ 16 | Disallow: /src/ 17 | Disallow: /tests/ 18 | Disallow: /tools/ 19 | Disallow: /dist/ 20 | Disallow: /.github/ 21 | Disallow: /.claude/ 22 | 23 | # Disallow development files 24 | Disallow: /package.json 25 | Disallow: /package-lock.json 26 | Disallow: /.gitignore 27 | Disallow: /vite.config.* 28 | Disallow: /eslint.config.* 29 | Disallow: /components.d.ts 30 | Disallow: /deploy*.ps1 31 | 32 | # Allow session sharing URLs (query parameters are fine) 33 | Allow: /*?session=* 34 | 35 | # Crawl delay (be nice to the server) 36 | Crawl-delay: 1 37 | 38 | # Sitemap location 39 | Sitemap: https://fabian-beck.github.io/pure-suggest/sitemap.xml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 fabian-beck 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/FpsTracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple FPS (Frames Per Second) tracker for performance monitoring 3 | */ 4 | export class FpsTracker { 5 | constructor() { 6 | this.frameCount = 0 7 | this.lastTime = 0 8 | this.fps = 0 9 | this.updateInterval = 0.5 // Update FPS display every 500ms 10 | } 11 | 12 | /** 13 | * Call this method on each frame/tick to update FPS calculation 14 | */ 15 | update() { 16 | this.frameCount++ 17 | const now = performance.now() 18 | 19 | if (this.lastTime === 0) { 20 | this.lastTime = now 21 | return 22 | } 23 | 24 | const deltaTime = (now - this.lastTime) / 1000 25 | 26 | if (deltaTime >= this.updateInterval) { 27 | this.fps = this.frameCount / deltaTime 28 | this.frameCount = 0 29 | this.lastTime = now 30 | } 31 | } 32 | 33 | /** 34 | * Get the current FPS value 35 | * @returns {number} Current FPS 36 | */ 37 | getFps() { 38 | return this.fps 39 | } 40 | 41 | /** 42 | * Reset the FPS tracker 43 | */ 44 | reset() { 45 | this.frameCount = 0 46 | this.lastTime = 0 47 | this.fps = 0 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { fileURLToPath, URL } from 'node:url' 4 | 5 | import Components from 'unplugin-vue-components/vite' 6 | 7 | export default defineConfig({ 8 | test: { 9 | environment: 'happy-dom', 10 | globals: true, 11 | setupFiles: ['./tests/setup.js'], 12 | css: false, // Disable CSS processing in tests 13 | include: ['tests/unit/**/*.test.js'], // Only unit tests 14 | testTimeout: 5000, // 5 second timeout for unit tests 15 | hookTimeout: 10000 // 10 second timeout for unit test hooks 16 | }, 17 | base: './', 18 | plugins: [vue(), Components({})], 19 | server: { 20 | port: 8080 21 | }, 22 | resolve: { 23 | alias: [ 24 | { 25 | find: '@', 26 | replacement: fileURLToPath(new URL('./src', import.meta.url)) 27 | } 28 | ] 29 | }, 30 | build: { 31 | chunkSizeWarningLimit: 1000, 32 | cssCodeSplit: false, 33 | target: 'esnext' 34 | }, 35 | css: { 36 | preprocessorOptions: { 37 | scss: { 38 | additionalData: `@use "@/assets/_shared.scss" as *;` 39 | } 40 | } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /src/components/PublicationTag.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 35 | 36 | 58 | -------------------------------------------------------------------------------- /src/components/basic/InfoDialog.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 37 | 38 | 45 | -------------------------------------------------------------------------------- /vite.config.perf.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { fileURLToPath, URL } from 'node:url' 4 | 5 | import Components from 'unplugin-vue-components/vite' 6 | 7 | export default defineConfig({ 8 | test: { 9 | environment: 'happy-dom', 10 | globals: true, 11 | setupFiles: ['./tests/setup.js'], 12 | css: false, // Disable CSS processing in tests 13 | include: ['tests/performance/**/*.test.js'], // Only performance tests 14 | testTimeout: 60000, // 60 second timeout for performance tests 15 | hookTimeout: 120000 // 2 minute timeout for dev server startup/shutdown 16 | }, 17 | base: './', 18 | plugins: [vue(), Components({})], 19 | server: { 20 | port: 8080 21 | }, 22 | resolve: { 23 | alias: [ 24 | { 25 | find: '@', 26 | replacement: fileURLToPath(new URL('./src', import.meta.url)) 27 | } 28 | ] 29 | }, 30 | build: { 31 | chunkSizeWarningLimit: 1000, 32 | cssCodeSplit: false, 33 | target: 'esnext' 34 | }, 35 | css: { 36 | preprocessorOptions: { 37 | scss: { 38 | additionalData: `@use "@/assets/_shared.scss" as *;` 39 | } 40 | } 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /src/stores/modal.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useModalStore = defineStore('modal', { 4 | state: () => { 5 | return { 6 | isSearchModalDialogShown: false, 7 | isAuthorModalDialogShown: false, 8 | isExcludedModalDialogShown: false, 9 | isQueueModalDialogShown: false, 10 | isAboutModalDialogShown: false, 11 | isShareSessionModalDialogShown: false, 12 | isKeyboardControlsModalDialogShown: false, 13 | confirmDialog: { 14 | message: '', 15 | action: () => {}, 16 | isShown: false, 17 | title: '' 18 | }, 19 | infoDialog: { 20 | message: '', 21 | isShown: false, 22 | title: '' 23 | }, 24 | searchQuery: '', 25 | searchProvider: 'openalex' 26 | } 27 | }, 28 | getters: { 29 | isAnyOverlayShown() { 30 | return ( 31 | this.confirmDialog.isShown || 32 | this.infoDialog.isShown || 33 | this.isSearchModalDialogShown || 34 | this.isAuthorModalDialogShown || 35 | this.isExcludedModalDialogShown || 36 | this.isQueueModalDialogShown || 37 | this.isAboutModalDialogShown || 38 | this.isShareSessionModalDialogShown || 39 | this.isKeyboardControlsModalDialogShown 40 | ) 41 | } 42 | }, 43 | actions: {} 44 | }) -------------------------------------------------------------------------------- /src/components/basic/ConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 39 | 40 | 47 | -------------------------------------------------------------------------------- /src/stores/author.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | import Author from '@/core/Author.js' 4 | 5 | export const useAuthorStore = defineStore('author', { 6 | state: () => { 7 | return { 8 | isAuthorScoreEnabled: true, 9 | isFirstAuthorBoostEnabled: true, 10 | isAuthorNewBoostEnabled: true, 11 | selectedPublicationsAuthors: [], 12 | activeAuthorId: null 13 | } 14 | }, 15 | getters: { 16 | activeAuthor: (state) => { 17 | if (!state.activeAuthorId) return null 18 | return ( 19 | state.selectedPublicationsAuthors.find((author) => author.id === state.activeAuthorId) || 20 | null 21 | ) 22 | }, 23 | 24 | isAuthorActive: (state) => (authorId) => { 25 | return state.activeAuthorId === authorId 26 | } 27 | }, 28 | 29 | actions: { 30 | computeSelectedPublicationsAuthors(selectedPublications) { 31 | this.selectedPublicationsAuthors = Author.computePublicationsAuthors( 32 | selectedPublications, 33 | this.isAuthorScoreEnabled, 34 | this.isFirstAuthorBoostEnabled, 35 | this.isAuthorNewBoostEnabled 36 | ) 37 | }, 38 | 39 | setActiveAuthor(authorId) { 40 | this.activeAuthorId = authorId 41 | }, 42 | 43 | clearActiveAuthor() { 44 | this.activeAuthorId = null 45 | } 46 | } 47 | }) 48 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createPinia } from 'pinia' 2 | import { createApp } from 'vue' 3 | import VueTippy from 'vue-tippy' 4 | import 'vuetify/styles' 5 | import { createVuetify } from 'vuetify' 6 | import * as components from 'vuetify/components' 7 | import * as directives from 'vuetify/directives' 8 | 9 | import App from './App.vue' 10 | import packageInfo from './../package.json' 11 | 12 | // App meta data 13 | 14 | // Pinia 15 | 16 | // Vuetify 17 | 18 | // Icons 19 | import '@mdi/font/css/materialdesignicons.css' 20 | 21 | // Bulma CSS 22 | import 'bulma/css/bulma.css' 23 | 24 | // Bulma color overrides (must be imported after Bulma) 25 | import './assets/bulma-color-overrides.css' 26 | 27 | // VueTippy 28 | import 'tippy.js/dist/tippy.css' 29 | 30 | const app = createApp(App) 31 | const appMeta = { 32 | name: 'PUREsuggest', 33 | nameHtml: 34 | 'PURE suggest', 35 | subtitle: 'Citation-based literature search', 36 | version: packageInfo.version 37 | } 38 | app.provide('appMeta', appMeta) 39 | const pinia = createPinia() 40 | app.use(pinia) 41 | const vuetify = createVuetify({ 42 | components, 43 | directives 44 | }) 45 | app.use(vuetify) 46 | app.use(VueTippy, { 47 | maxWidth: 'min(400px,70vw)', 48 | directive: 'tippy', // => v-tippy 49 | component: 'tippy', // => 50 | defaultProps: { 51 | allowHTML: true 52 | } 53 | }) 54 | 55 | app.mount('#app') 56 | -------------------------------------------------------------------------------- /src/assets/bulma-color-overrides.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Bulma v1.0 Color Overrides 3 | * 4 | * This file restores the original Bulma 0.9.4 color scheme to maintain 5 | * the original visual appearance after upgrading to Bulma v1.0.4 6 | * 7 | * Since we're using pre-compiled Bulma CSS, we override the base HSL 8 | * components that Bulma v1.0 uses to generate all color variants. 9 | */ 10 | 11 | :root { 12 | /* Original Bulma 0.9.4 Primary Color (Turquoise) */ 13 | --bulma-primary-h: 171deg; 14 | --bulma-primary-s: 100%; 15 | --bulma-primary-l: 41%; 16 | 17 | /* Original Bulma 0.9.4 Info Color (Cyan) - #3e8ed0 */ 18 | --bulma-info-h: 207deg; 19 | --bulma-info-s: 61%; 20 | --bulma-info-l: 53%; 21 | 22 | /* Original Bulma 0.9.4 Success Color (Green) */ 23 | --bulma-success-h: 153deg; 24 | --bulma-success-s: 53%; 25 | --bulma-success-l: 53%; 26 | 27 | /* Original Bulma 0.9.4 Warning Color (Yellow) */ 28 | --bulma-warning-h: 44deg; 29 | --bulma-warning-s: 100%; 30 | --bulma-warning-l: 77%; 31 | 32 | /* Original Bulma 0.9.4 Danger Color (Red) */ 33 | --bulma-danger-h: 348deg; 34 | --bulma-danger-s: 86%; 35 | --bulma-danger-l: 61%; 36 | 37 | /* Original Bulma 0.9.4 Dark Color */ 38 | --bulma-dark-h: 0deg; 39 | --bulma-dark-s: 0%; 40 | --bulma-dark-l: 21%; 41 | 42 | /* Fix conflicting text colors between Bulma and Vuetify */ 43 | .has-text-white { 44 | color: white !important; 45 | } 46 | 47 | .has-background-dark.has-text-white { 48 | color: white !important; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/filterUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for publication filtering to reduce code duplication 3 | */ 4 | 5 | /** 6 | * Filters and sorts publications based on filter state 7 | * @param {Array} publications - Array of publications to process 8 | * @param {Object} filter - Filter object with hasActiveFilters() and matches() methods 9 | * @param {boolean} shouldApply - Whether to apply the filter 10 | * @returns {Array} Filtered and sorted publications 11 | */ 12 | export function getFilteredPublications(publications, filter, shouldApply) { 13 | if (!filter.hasActiveFilters() || !shouldApply) { 14 | return publications 15 | } 16 | 17 | const sorter = (a, b) => { 18 | const aMatches = filter.matches(a) 19 | const bMatches = filter.matches(b) 20 | 21 | if (aMatches && !bMatches) return -1 22 | if (!aMatches && bMatches) return 1 23 | 24 | return b.score - a.score 25 | } 26 | 27 | return [...publications].sort(sorter) 28 | } 29 | 30 | /** 31 | * Counts publications matching a filter condition 32 | * @param {Array} publications - Array of publications to count 33 | * @param {Object} filter - Filter object with hasActiveFilters() and matches() methods 34 | * @param {boolean} matchingFilter - If true, count matching publications; if false, count non-matching 35 | * @returns {number} Count of publications 36 | */ 37 | export function countFilteredPublications(publications, filter, matchingFilter = true) { 38 | if (!filter.hasActiveFilters()) return 0 39 | 40 | return publications.filter((publication) => 41 | matchingFilter ? filter.matches(publication) : !filter.matches(publication) 42 | ).length 43 | } 44 | -------------------------------------------------------------------------------- /.claude/commands/cleanup-tests.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Analyze unit tests for performance optimization and cleanup opportunities 3 | argument-hint: [test-directory] 4 | --- 5 | 6 | Analyze the unit test suite to identify optimization opportunities and suggest improvements: 7 | 8 | ## Performance Analysis 9 | - **Execution Time**: Identify tests with excessive runtime (>100ms) and investigate root causes 10 | - **Heavy Mounting**: Find component tests that mount full trees when simpler approaches would suffice 11 | - **Complex Mocking**: Locate tests where mock setup complexity outweighs the actual test value 12 | - **Async Delays**: Detect unnecessary setTimeout/delay patterns that slow test execution 13 | 14 | ## Quality Assessment 15 | - **Trivial Tests**: Flag tests that verify basic language or framework behavior rather than business logic 16 | - **Duplicate Coverage**: Identify tests that redundantly verify the same functionality 17 | - **Brittle Tests**: Find tests that break easily with minor implementation changes 18 | - **Over-Engineering**: Spot tests with unnecessarily complex setup, assertions, or mocking patterns 19 | 20 | ## Optimization Recommendations 21 | - Prioritize tests with high execution time but low added value 22 | - Suggest converting integration-style tests to focused unit tests 23 | - Recommend consolidation of similar test scenarios 24 | - Propose simpler testing approaches for complex mock scenarios 25 | - Identify candidates for test removal vs. refactoring 26 | 27 | Provide specific, actionable recommendations that balance test execution speed with comprehensive coverage of critical functionality. Focus on the unit test directory structure and patterns rather than performance tests. 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pure", 3 | "version": "0.11.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview --open", 9 | "lint": "eslint src tests tools", 10 | "format": "prettier --write .", 11 | "format:check": "prettier --check .", 12 | "test": "vitest run", 13 | "test:watch": "vitest", 14 | "test:ui": "vitest --ui", 15 | "test:coverage": "vitest --coverage", 16 | "test:perf": "vitest run --config vite.config.perf.mjs", 17 | "test:perf:watch": "vitest --config vite.config.perf.mjs", 18 | "test:all": "npm test && npm run test:perf" 19 | }, 20 | "dependencies": { 21 | "@mdi/font": "^7.4.47", 22 | "bulma": "^1.0.4", 23 | "core-js": "^3.21.0", 24 | "d3": "^7", 25 | "idb-keyval": "^6.2.1", 26 | "lz-string": "^1.4.4", 27 | "pinia": "^3.0.3", 28 | "sass": "^1.93.2", 29 | "tippy.js": "^6.3.7", 30 | "vue": "^3.5.22", 31 | "vue-tippy": "^6.3.1", 32 | "vuetify": "^3.10.3" 33 | }, 34 | "devDependencies": { 35 | "@testing-library/vue": "^8.1.0", 36 | "@vitejs/plugin-vue": "^5.2.4", 37 | "@vue/compiler-sfc": "^3.5.22", 38 | "@vue/test-utils": "^2.4.6", 39 | "eslint": "^9.36.0", 40 | "eslint-config-prettier": "^10.1.8", 41 | "eslint-plugin-import": "^2.32.0", 42 | "eslint-plugin-sonarjs": "^3.0.5", 43 | "eslint-plugin-vue": "^10.5.0", 44 | "globals": "^16.4.0", 45 | "happy-dom": "^20.0.0", 46 | "jsdom": "^27.0.0", 47 | "prettier": "^3.6.2", 48 | "puppeteer": "^24.22.3", 49 | "unplugin-vue-components": "^30.0.0", 50 | "vite": "^6.3.5", 51 | "vitest": "^3.2.4" 52 | }, 53 | "browserslist": [ 54 | "> 1%", 55 | "last 2 versions" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/performance.js: -------------------------------------------------------------------------------- 1 | // Performance monitoring utilities 2 | class PerformanceMonitor { 3 | constructor() { 4 | this.enabled = true // Set to false in production 5 | } 6 | 7 | logMemoryUsage() { 8 | if (!this.enabled || !performance.memory) return 9 | const memory = performance.memory 10 | console.debug( 11 | `[PERF] Memory: Used ${(memory.usedJSHeapSize / 1024 / 1024).toFixed(2)}MB / Total ${(memory.totalJSHeapSize / 1024 / 1024).toFixed(2)}MB` 12 | ) 13 | } 14 | 15 | logPageMetrics() { 16 | if (!this.enabled) return 17 | // Log navigation timing 18 | if (performance.navigation && performance.timing) { 19 | const timing = performance.timing 20 | console.group('[PERF] Page Load Metrics') 21 | console.debug(`DNS Lookup: ${timing.domainLookupEnd - timing.domainLookupStart}ms`) 22 | console.debug(`TCP Connection: ${timing.connectEnd - timing.connectStart}ms`) 23 | console.debug(`Request/Response: ${timing.responseEnd - timing.requestStart}ms`) 24 | console.debug(`DOM Processing: ${timing.domComplete - timing.domLoading}ms`) 25 | console.debug(`Total Load Time: ${timing.loadEventEnd - timing.navigationStart}ms`) 26 | console.groupEnd() 27 | } 28 | 29 | // Log performance entries 30 | const entries = performance.getEntriesByType('navigation') 31 | if (entries.length > 0) { 32 | const navigation = entries[0] 33 | console.group('[PERF] Navigation Timing') 34 | console.debug(`DOM Content Loaded: ${navigation.domContentLoadedEventEnd}ms`) 35 | console.debug(`Load Complete: ${navigation.loadEventEnd}ms`) 36 | console.groupEnd() 37 | } 38 | } 39 | } 40 | 41 | // Create global instance 42 | export const perfMonitor = new PerformanceMonitor() 43 | -------------------------------------------------------------------------------- /test/sessions/40-selected.infovis_perception_cognition.json: -------------------------------------------------------------------------------- 1 | { 2 | "selected": [ 3 | "10.1016/j.cag.2014.03.002", 4 | "10.2200/s00685ed1v01y201512vis005", 5 | "10.1002/asi.23002", 6 | "10.1109/tvcg.2008.121", 7 | "10.1007/978-1-4614-7485-2_28", 8 | "10.1109/tvcg.2010.177", 9 | "10.1179/0308018812z.0000000001", 10 | "10.1057/ivs.2008.28", 11 | "10.1006/ijhc.1996.0048", 12 | "10.1007/978-1-4614-7485-2_27", 13 | "10.1109/vast.2008.4677361", 14 | "10.1111/j.1756-8765.2011.01150.x", 15 | "10.1109/tvcg.2007.70515", 16 | "10.1016/j.apergo.2020.103173", 17 | "10.1007/978-3-319-66435-4_6", 18 | "10.1109/tvcg.2011.127", 19 | "10.1007/978-3-642-19641-6_4", 20 | "10.1057/ivs.2009.10", 21 | "10.1109/tvcg.2004.1260759", 22 | "10.1080/01621459.1984.10478080", 23 | "10.1007/978-3-030-34784-0_21", 24 | "10.1145/353485.353487", 25 | "10.1145/2993901.2993902", 26 | "10.1016/j.eswa.2010.11.025", 27 | "10.1207/s15516709cog1801_3", 28 | "10.2200/s00651ed1v01y201506vis003", 29 | "10.1016/j.dss.2019.05.001", 30 | "10.1177/1473871616638546", 31 | "10.1007/978-1-4614-7485-2_5", 32 | "10.1111/j.1756-8765.2011.01148.x", 33 | "10.7551/mitpress/6137.001.0001", 34 | "10.1007/978-3-030-34444-3_3", 35 | "10.1111/cgf.13595", 36 | "10.17705/1thci.00055", 37 | "10.1111/j.1551-6708.1987.tb00863.x", 38 | "10.1007/s00146-010-0272-8", 39 | "10.1109/tvcg.2007.28", 40 | "10.1007/s12650-021-00778-8", 41 | "10.1109/iv.2007.114", 42 | "10.1016/b978-012387582-2/50043-5" 43 | ], 44 | "excluded": [ 45 | "10.1109/tvcg.2009.111", 46 | "10.1007/978-3-642-03202-8_25", 47 | "10.1109/hsi.2008.4581434", 48 | "10.1007/s12650-020-00705-3", 49 | "10.1109/vl.1996.545307", 50 | "10.1080/25742442.2019.1637082" 51 | ], 52 | "boost": "perc, visu, cogn, human, inform, graphi" 53 | } 54 | -------------------------------------------------------------------------------- /src/components/AuthorGlyph.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 49 | 50 | 55 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import { config } from '@vue/test-utils' 2 | import { createPinia } from 'pinia' 3 | 4 | // Global test setup 5 | config.global.plugins = [createPinia()] 6 | 7 | config.global.stubs = { 8 | // Stub Vuetify components globally with proper templates 9 | 'v-btn': { template: '' }, 10 | 'v-btn-toggle': { template: '
' }, 11 | 'v-icon': { template: '' }, 12 | 'v-card': { template: '
' }, 13 | 'v-dialog': { template: '
' }, 14 | 'v-switch': { template: '' }, 15 | 'v-checkbox': { template: '' }, 16 | 'v-col': { template: '
' }, 17 | 'v-row': { template: '
' }, 18 | 'v-text-field': { template: '' }, 19 | 'v-select': { template: '' }, 20 | 'v-chip': { template: '' }, 21 | 'v-sheet': { template: '
' }, 22 | 'v-menu': { template: '
' }, 23 | 'v-list': { template: '' }, 24 | 'v-list-item': { template: '
  • ' }, 25 | 'v-list-item-title': { template: '
    ' }, 26 | 'v-slider': { template: '' } 27 | } 28 | 29 | // Mock directives 30 | config.global.directives = { 31 | tippy: { 32 | mounted() {}, 33 | unmounted() {}, 34 | updated() {} 35 | } 36 | } 37 | 38 | // Mock any global properties or plugins 39 | config.global.mocks = { 40 | // Add mocks here if needed 41 | } 42 | 43 | // Suppress console output during tests to reduce noise 44 | if (process.env.NODE_ENV === 'test' || process.env.VITEST) { 45 | console.log = () => {} 46 | console.warn = () => {} 47 | } 48 | -------------------------------------------------------------------------------- /tests/unit/Publication.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest' 2 | 3 | import Publication from '@/core/Publication.js' 4 | 5 | // Mock dependencies 6 | vi.mock('@/lib/Cache.js', () => ({ 7 | cachedFetch: vi.fn() 8 | })) 9 | 10 | describe('Publication Bug Regression Tests', () => { 11 | let publication 12 | 13 | beforeEach(() => { 14 | // Reset current year constant for predictable tests 15 | vi.doMock('@/constants/publication.js', () => ({ 16 | CURRENT_YEAR: 2024, 17 | SCORING: { 18 | DEFAULT_BOOST_FACTOR: 1 19 | }, 20 | SURVEY_THRESHOLDS: {}, 21 | CITATION_THRESHOLDS: {}, 22 | PUBLICATION_AGE: {}, 23 | TEXT_PROCESSING: {}, 24 | SURVEY_KEYWORDS: [], 25 | ORDINAL_REGEX: /\d+/, 26 | ROMAN_NUMERAL_REGEX: /[IVX]+/, 27 | ORCID_REGEX: /\d{4}-\d{4}-\d{4}-\d{3}[0-9Xx]/, 28 | TITLE_WORD_MAP: {}, 29 | PUBLICATION_TAGS: {} 30 | })) 31 | }) 32 | 33 | describe('citationsPerYear calculation - Bug Fix Regression', () => { 34 | beforeEach(() => { 35 | publication = new Publication('10.1234/test') 36 | }) 37 | 38 | it('should handle missing year data without NaN', () => { 39 | publication.year = null 40 | publication.citationDois = ['10.1234/citation1'] 41 | 42 | const result = publication.citationsPerYear 43 | expect(result).toBe(1) // Should be 1 citation / 1 year (fallback), not NaN 44 | expect(Number.isNaN(result)).toBe(false) 45 | }) 46 | 47 | it('should handle undefined year without NaN', () => { 48 | publication.year = undefined 49 | publication.citationDois = ['10.1234/citation1', '10.1234/citation2'] 50 | 51 | const result = publication.citationsPerYear 52 | expect(result).toBe(2) // Should be 2 citations / 1 year (fallback), not NaN 53 | expect(Number.isNaN(result)).toBe(false) 54 | }) 55 | 56 | it('should handle future publication years gracefully', () => { 57 | publication.year = 2025 // Future year 58 | publication.citationDois = ['10.1234/citation1'] 59 | 60 | const result = publication.citationsPerYear 61 | expect(result).toBe(1) // Should use Math.max(1, negative_value) = 1 62 | expect(Number.isNaN(result)).toBe(false) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /.claude/commands/cleanup-unused.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Find and clean unused legacy code by identifying dead code, unused imports, obsolete functions, and deprecated patterns using automated tools. 3 | --- 4 | 5 | Find and clean unused legacy code using the automated unused function detection tool: 6 | 7 | ## Quick Analysis 8 | 9 | Run the automated unused function finder: 10 | 11 | ```bash 12 | node tools/find-unused-functions.js 13 | ``` 14 | 15 | This tool analyzes the codebase for: 16 | 17 | 1. **Dead code** - Functions, methods, or classes that are never called or imported 18 | 2. **Unused imports** - Import statements that don't have corresponding usage 19 | 3. **Obsolete functions** - Legacy utility functions that have been replaced by newer implementations 20 | 4. **Deprecated patterns** - Old Vue 2 patterns, unused Vuetify components, or outdated CSS classes 21 | 5. **Unreferenced files** - Files that are no longer imported or used anywhere in the project 22 | 6. **Commented-out code** - Old code blocks that are commented out and no longer needed 23 | 24 | ## Manual Verification Required 25 | 26 | The tool provides candidates that need manual verification. For each identified unused piece, verify it's truly unused by checking: 27 | 28 | - Import statements across the codebase 29 | - Dynamic imports or string-based references 30 | - Template usage in Vue components 31 | - Test file references 32 | - Vue component method calls via `$refs` 33 | - Store method calls from components 34 | 35 | ## Focus Areas 36 | 37 | The analysis covers: 38 | 39 | - Vue components and composables in `src/components/` and `src/composables/` 40 | - Utility functions in `src/lib/` and `src/core/` 41 | - Store modules that may have unused getters/actions 42 | - Test files that test removed functionality 43 | 44 | ## Safe Removal Guidelines 45 | 46 | Prioritize removal candidates with highest confidence: 47 | 48 | 1. **Test utilities never imported** - Safe to remove 49 | 2. **Private methods with single occurrence** - Usually safe 50 | 3. **Legacy helper functions** - Check for string-based calls first 51 | 4. **Store methods never called** - Verify no dynamic access 52 | 5. **Component methods** - Check template usage and ref calls 53 | 54 | After verification, remove unused code and commit changes with descriptive messages. 55 | -------------------------------------------------------------------------------- /src/lib/Util.js: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server?answertab=active#tab-top 2 | export function saveAsFile(filename, mime, data) { 3 | const blob = new Blob([data], { type: mime }) 4 | if (window.navigator.msSaveOrOpenBlob) { 5 | window.navigator.msSaveBlob(blob, filename) 6 | } else { 7 | const elem = window.document.createElement('a') 8 | elem.href = window.URL.createObjectURL(blob) 9 | elem.download = filename 10 | document.body.appendChild(elem) 11 | elem.click() 12 | document.body.removeChild(elem) 13 | } 14 | } 15 | 16 | // https://stackoverflow.com/questions/49820013/javascript-scrollintoview-smooth-scroll-and-offset 17 | export function scrollToTargetAdjusted(element, offsetY) { 18 | const elementPosition = element.getBoundingClientRect().top 19 | const offsetPosition = elementPosition + window.pageYOffset - offsetY 20 | 21 | window.scrollTo({ 22 | top: offsetPosition, 23 | behavior: 'smooth' 24 | }) 25 | } 26 | 27 | // https://stackoverflow.com/questions/16801687/javascript-random-ordering-with-seed 28 | export function shuffle(array, seed) { 29 | function random(seed) { 30 | const x = Math.sin(seed++) * 10000 31 | return x - Math.floor(x) 32 | } 33 | 34 | let m = array.length, 35 | t, 36 | i 37 | while (m) { 38 | i = Math.floor(random(seed) * m--) 39 | t = array[m] 40 | array[m] = array[i] 41 | array[i] = t 42 | ++seed 43 | } 44 | return array 45 | } 46 | 47 | // This function is used to parse a BibTeX file and extract all DOIs from it and returns a "session" 48 | export function bibtexParser(file) { 49 | return new Promise((resolve, reject) => { 50 | const reader = new FileReader() 51 | 52 | reader.readAsText(file) 53 | 54 | reader.onload = function (event) { 55 | const content = event.target.result 56 | 57 | const dois = [] 58 | const output = { selected: dois } 59 | 60 | // Regex: Finds all occurences statring with DOI, any whitespace, =, any whitespace, match { or " , capture anything but } or " , and finally match } or } 61 | const doiRegex = /doi\s*=\s*[{"]([^}"']+)[}"]/gi 62 | 63 | let match 64 | while ((match = doiRegex.exec(content)) !== null) { 65 | const doi = match[1].trim() 66 | dois.push(doi) 67 | } 68 | 69 | resolve(output) 70 | } 71 | 72 | // Error handling 73 | reader.onerror = function () { 74 | reject(new Error('Error reading the BibTeX file')) 75 | } 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /tests/unit/components/PublicationComponentSearch.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { describe, it, expect, vi } from 'vitest' 3 | 4 | import PublicationComponentSearch from '@/components/PublicationComponentSearch.vue' 5 | 6 | // Mock IndexedDB for this test since PublicationDescription imports things that need it 7 | global.indexedDB = { 8 | open: vi.fn(() => ({ 9 | onsuccess: null, 10 | onerror: null, 11 | result: { 12 | createObjectStore: vi.fn(() => ({})), 13 | transaction: vi.fn(() => ({ 14 | objectStore: vi.fn(() => ({ 15 | add: vi.fn(() => ({})), 16 | get: vi.fn(() => ({})), 17 | getAll: vi.fn(() => ({})), 18 | delete: vi.fn(() => ({})) 19 | })) 20 | })) 21 | } 22 | })) 23 | } 24 | 25 | // Mock Cache to avoid IndexedDB issues 26 | vi.mock('@/lib/Cache.js', () => ({ 27 | get: vi.fn(), 28 | set: vi.fn(), 29 | keys: vi.fn(() => Promise.resolve([])), 30 | clearCache: vi.fn() 31 | })) 32 | 33 | describe('PublicationComponentSearch', () => { 34 | const mockPublication = { 35 | doi: '10.1234/test-publication', 36 | title: 'Test Publication Title', 37 | author: 'John Doe', 38 | year: 2023, 39 | wasFetched: true 40 | } 41 | 42 | it('renders with correct structure', () => { 43 | const wrapper = mount(PublicationComponentSearch, { 44 | props: { 45 | publication: mockPublication, 46 | searchQuery: 'Test' 47 | }, 48 | global: { 49 | stubs: { 50 | PublicationDescription: true, 51 | CompactButton: true 52 | } 53 | } 54 | }) 55 | 56 | expect(wrapper.find('.publication-component').exists()).toBe(true) 57 | expect(wrapper.find('.media-content').exists()).toBe(true) 58 | expect(wrapper.find('.media-right').exists()).toBe(true) 59 | }) 60 | 61 | it('passes correct props to PublicationDescription', () => { 62 | const wrapper = mount(PublicationComponentSearch, { 63 | props: { 64 | publication: mockPublication, 65 | searchQuery: 'Test Query' 66 | }, 67 | global: { 68 | stubs: { 69 | PublicationDescription: true, 70 | CompactButton: true 71 | } 72 | } 73 | }) 74 | 75 | const description = wrapper.getComponent({ name: 'PublicationDescription' }) 76 | expect(description.props('publication')).toEqual(mockPublication) 77 | expect(description.props('highlighted')).toBe('Test Query') 78 | expect(description.props('alwaysShowDetails')).toBe(true) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /tests/unit/utils/authorColor.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { calculateAuthorColor } from '@/utils/authorColor.js' 4 | 5 | describe('calculateAuthorColor', () => { 6 | const mockAuthorStore = { 7 | isAuthorScoreEnabled: true, 8 | isFirstAuthorBoostEnabled: true, 9 | isAuthorNewBoostEnabled: true 10 | } 11 | 12 | it('should return darker color for higher scores', () => { 13 | const highScore = 20 14 | const lowScore = 5 15 | 16 | const highScoreColor = calculateAuthorColor(highScore, mockAuthorStore) 17 | const lowScoreColor = calculateAuthorColor(lowScore, mockAuthorStore) 18 | 19 | // Extract lightness values 20 | const highScoreLightness = parseInt(highScoreColor.match(/hsl\(0, 0%, (\d+)%/)[1], 10) 21 | const lowScoreLightness = parseInt(lowScoreColor.match(/hsl\(0, 0%, (\d+)%/)[1], 10) 22 | 23 | // Higher score should result in lower lightness (darker color) 24 | expect(highScoreLightness).toBeLessThan(lowScoreLightness) 25 | }) 26 | 27 | it('should return HSL color string format', () => { 28 | const score = 10 29 | const color = calculateAuthorColor(score, mockAuthorStore) 30 | 31 | expect(color).toMatch(/^hsl\(0, 0%, \d+%\)$/) 32 | }) 33 | 34 | it('should adjust score based on author store settings', () => { 35 | const score = 10 36 | 37 | // Test with score disabled (should multiply by 20) 38 | const storeScoreDisabled = { ...mockAuthorStore, isAuthorScoreEnabled: false } 39 | const colorScoreDisabled = calculateAuthorColor(score, storeScoreDisabled) 40 | 41 | // Test with all boosts disabled 42 | const storeBoostsDisabled = { 43 | isAuthorScoreEnabled: true, 44 | isFirstAuthorBoostEnabled: false, 45 | isAuthorNewBoostEnabled: false 46 | } 47 | const colorBoostsDisabled = calculateAuthorColor(score, storeBoostsDisabled) 48 | 49 | // Test with default settings 50 | const colorDefault = calculateAuthorColor(score, mockAuthorStore) 51 | 52 | // All should be valid HSL colors 53 | expect(colorScoreDisabled).toMatch(/^hsl\(0, 0%, \d+%\)$/) 54 | expect(colorBoostsDisabled).toMatch(/^hsl\(0, 0%, \d+%\)$/) 55 | expect(colorDefault).toMatch(/^hsl\(0, 0%, \d+%\)$/) 56 | 57 | // Different settings should produce different results 58 | expect(colorScoreDisabled).not.toBe(colorDefault) 59 | expect(colorBoostsDisabled).not.toBe(colorDefault) 60 | }) 61 | 62 | it('should never return negative lightness values', () => { 63 | const veryHighScore = 1000 64 | const color = calculateAuthorColor(veryHighScore, mockAuthorStore) 65 | 66 | const lightness = parseInt(color.match(/hsl\(0, 0%, (\d+)%/)[1], 10) 67 | expect(lightness).toBeGreaterThanOrEqual(0) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tests/unit/features/keyboard-navigation-improved.test.js: -------------------------------------------------------------------------------- 1 | import { createPinia, setActivePinia } from 'pinia' 2 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' 3 | 4 | import { useInterfaceStore } from '@/stores/interface.js' 5 | 6 | // Mock scrollIntoView 7 | const mockScrollIntoView = vi.fn() 8 | 9 | describe('Radical Keyboard Navigation Scrolling: Always Center', () => { 10 | let interfaceStore 11 | 12 | beforeEach(() => { 13 | vi.useFakeTimers() 14 | setActivePinia(createPinia()) 15 | interfaceStore = useInterfaceStore() 16 | 17 | // Mock scrollIntoView on Element prototype 18 | Element.prototype.scrollIntoView = mockScrollIntoView 19 | 20 | // Clear mock calls before each test 21 | mockScrollIntoView.mockClear() 22 | }) 23 | 24 | afterEach(() => { 25 | vi.useRealTimers() 26 | }) 27 | 28 | it('should always center publications during keyboard navigation', async () => { 29 | const mockComponent = { 30 | focus: vi.fn(), 31 | scrollIntoView: mockScrollIntoView 32 | } 33 | 34 | // Test DOWN navigation 35 | interfaceStore.activatePublicationComponent(mockComponent, 'down') 36 | 37 | // Verify focus was called 38 | expect(mockComponent.focus).toHaveBeenCalled() 39 | expect(interfaceStore.lastNavigationDirection).toBe('down') 40 | 41 | // Advance timers to trigger setTimeout 42 | await vi.runAllTimersAsync() 43 | 44 | // Should always center regardless of direction 45 | expect(mockScrollIntoView).toHaveBeenCalledWith({ 46 | behavior: "smooth", 47 | block: "center", 48 | inline: "nearest" 49 | }) 50 | }) 51 | 52 | it('should always center for UP navigation too', async () => { 53 | const mockComponent = { 54 | focus: vi.fn(), 55 | scrollIntoView: mockScrollIntoView 56 | } 57 | 58 | // Test UP navigation 59 | interfaceStore.activatePublicationComponent(mockComponent, 'up') 60 | 61 | // Advance timers to trigger setTimeout 62 | await vi.runAllTimersAsync() 63 | 64 | // Should always center regardless of direction 65 | expect(mockScrollIntoView).toHaveBeenCalledWith({ 66 | behavior: "smooth", 67 | block: "center", 68 | inline: "nearest" 69 | }) 70 | }) 71 | 72 | it('should not scroll when no navigationDirection is provided', async () => { 73 | const mockComponent = { 74 | focus: vi.fn() 75 | } 76 | 77 | // Call without navigationDirection 78 | interfaceStore.activatePublicationComponent(mockComponent) 79 | 80 | // Advance timers to handle any potential async operations 81 | await vi.runAllTimersAsync() 82 | 83 | // Should not trigger scrolling without navigationDirection 84 | expect(mockScrollIntoView).not.toHaveBeenCalled() 85 | }) 86 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PUREsuggest – Citation-based literature search 2 | 3 | _PUREsuggest_ is a scientific literature search tool that, starting from some seed papers, suggests scientific publications through citations/references. 4 | 5 | The tool is available at: https://fabian-beck.github.io/pure-suggest/ 6 | 7 | ![Interface of PUREsuggest](pure_suggest.png) 8 | 9 | ## Key features 10 | 11 | - _Select_ multiple publications and get suggestions based on citations of these 12 | - _Inspect_ the suggestions ranked by a citation score 13 | - _Boost_ the score with specific keywords of interest 14 | - _Visualize_ citations in a network diagram on a cluster map or timeline 15 | 16 | ## Additional functionality 17 | 18 | - Add publications to selection by DOIs or search 19 | - View the main authors of the selected publications 20 | - Filter publications by different criteria 21 | - Automatic tagging of publication characteristics (e.g., ”highly cited”) 22 | - Quick access to publications through links (DOIs, open access versions, or Google Scholar) 23 | - Keyboard controls for an efficient workflow 24 | - Export/import session and share as link 25 | - Export/import selected publications to/from BibTeX 26 | - Responsive design that supports screen sizes from phone up to ultra-wide displays 27 | 28 | ## Read more 29 | 30 | ### Blog 31 | 32 | We publish news as part of our blog on Medium.com: https://medium.com/@pure_suggest 33 | 34 | ### Scientific publications 35 | 36 | If you want to learn more on the background of the tool or cite it, you can refer to the following publication: 37 | 38 | - **[Main Publication]** Beck, F., 2025. _PUREsuggest: Citation-Based Literature Search and Visual Exploration with Keyword-Controlled Rankings._ In IEEE Transactions on Visualization and Computer Graphics, 31(1), 316-326. DOI: [10.1109/TVCG.2024.3456199](https://doi.org/10.1109/TVCG.2024.3456199). [[PDF](https://arxiv.org/pdf/2408.02508)] 39 | - Rabsahl, S. and Beck, F., 2024. _A Multi-layer Event Visualization for Exploring User Search Patterns in Literature Discovery with PUREsuggest._ In Proceedings of Mensch und Computer 2024. DOI: [10.1145/3670653.3677502](https://doi.org/10.1145/3670653.3677502) 40 | - Beck, F., 2022. _Assessing Discussions of Related Work through Citation-based Recommendations and Network Visualization._ In Workshop on Open Citations and Open Scholarly Metadata 2022. DOI: [10.5281/zenodo.7123500](https://doi.org/10.5281/zenodo.7123500). 41 | - Beck, F. and Krause, C., 2022. _Visually Explaining Publication Ranks in Citation-based Literature Search with PURE suggest._ In EuroVis 2022 - Posters. DOI: [10.2312/evp.20221110](https://diglib.eg.org/handle/10.2312/evp20221110). [[PDF](https://diglib.eg.org/bitstream/handle/10.2312/evp20221110/019-021.pdf)] 42 | 43 | ## Project setup 44 | 45 | ``` 46 | npm install 47 | ``` 48 | 49 | ### Compiles and hot-reloads for development 50 | 51 | ``` 52 | npm run dev 53 | ``` 54 | 55 | ### Compiles and minifies for production 56 | 57 | ``` 58 | npm run build 59 | ``` 60 | -------------------------------------------------------------------------------- /src/utils/network/yearLabels.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Year Labels Management for Network Visualization 3 | * 4 | * This composable handles the creation and positioning of year labels 5 | * in timeline mode for the citation network visualization. 6 | */ 7 | 8 | import { CURRENT_YEAR } from '@/constants/config.js' 9 | 10 | const MARGIN = 50 11 | 12 | /** 13 | * Generate array of years to display as labels (every 5 years) 14 | */ 15 | export function generateYearRange(yearMin, yearMax) { 16 | if (yearMin === undefined || yearMax === undefined) { 17 | return [] 18 | } 19 | 20 | const rangeLength = yearMax - yearMin + 6 21 | const startYear = yearMin - 4 22 | 23 | return Array.from({ length: rangeLength }, (_, i) => startYear + i).filter( 24 | (year) => year % 5 === 0 25 | ) 26 | } 27 | 28 | /** 29 | * Initialize year label elements in the SVG 30 | */ 31 | export function initializeYearLabels(labelSelection, yearRange) { 32 | return labelSelection 33 | .data(yearRange, (d) => d) 34 | .join((enter) => { 35 | const g = enter.append('g') 36 | g.append('rect') 37 | g.append('text') 38 | g.append('text') 39 | return g 40 | }) 41 | } 42 | 43 | /** 44 | * Update year label rectangles (background stripes) 45 | */ 46 | export function updateYearLabelRects(labelSelection, yearXCalculator) { 47 | labelSelection 48 | .selectAll('rect') 49 | .attr('width', (d) => yearXCalculator(Math.min(d + 5, CURRENT_YEAR)) - yearXCalculator(d)) 50 | .attr('height', 20000) 51 | .attr('fill', (d) => (d % 10 === 0 ? '#fafafa' : 'white')) 52 | .attr('x', -24) 53 | .attr('y', -10000) 54 | } 55 | 56 | /** 57 | * Update year label text elements 58 | */ 59 | export function updateYearLabelText(labelSelection) { 60 | labelSelection 61 | .selectAll('text') 62 | .attr('text-anchor', 'middle') 63 | .text((d) => d) 64 | .attr('fill', 'grey') 65 | } 66 | 67 | /** 68 | * Set visibility and positioning of year labels 69 | */ 70 | export function updateYearLabelVisibility(labelSelection, isVisible, yearXCalculator, svgHeight) { 71 | labelSelection.selectAll('text, rect').attr('visibility', isVisible ? 'visible' : 'hidden') 72 | 73 | if (isVisible) { 74 | labelSelection 75 | .attr('transform', (d) => `translate(${yearXCalculator(d)}, ${svgHeight / 2 - MARGIN})`) 76 | .select('text') 77 | .attr('y', -svgHeight + 2 * MARGIN) 78 | } 79 | } 80 | 81 | /** 82 | * Updates year label content (data binding and text) 83 | * @param {Object} labelSelection - D3 selection for year labels 84 | * @param {Array} yearRange - Array of years to display 85 | * @returns {Object} Updated label selection 86 | */ 87 | export function updateYearLabelContent(labelSelection, yearRange) { 88 | if (!labelSelection) { 89 | throw new Error('Label selection is undefined - cannot update year label content') 90 | } 91 | 92 | const updatedLabels = initializeYearLabels(labelSelection, yearRange) 93 | updateYearLabelText(updatedLabels) 94 | return updatedLabels 95 | } 96 | 97 | -------------------------------------------------------------------------------- /src/components/modal/ShareSessionModalDialog.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 94 | 95 | 98 | -------------------------------------------------------------------------------- /tests/unit/components/HeaderPanel.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { describe, it, expect, vi, beforeEach } from 'vitest' 3 | 4 | import HeaderPanel from '@/components/HeaderPanel.vue' 5 | 6 | // Mock stores 7 | const mockSessionStore = { 8 | isEmpty: true, 9 | selectedPublicationsCount: 0, 10 | excludedPublicationsCount: 0, 11 | exportSession: vi.fn(), 12 | exportAllBibtex: vi.fn(), 13 | clearSession: vi.fn(), 14 | clearCache: vi.fn() 15 | } 16 | 17 | const mockInterfaceStore = { 18 | isMobile: false, 19 | isExcludedModalDialogShown: false, 20 | isKeyboardControlsModalDialogShown: false, 21 | isAboutModalDialogShown: false 22 | } 23 | 24 | // Mock the store imports 25 | vi.mock('@/stores/session.js', () => ({ 26 | useSessionStore: () => mockSessionStore 27 | })) 28 | 29 | vi.mock('@/stores/interface.js', () => ({ 30 | useInterfaceStore: () => mockInterfaceStore 31 | })) 32 | 33 | vi.mock('@/composables/useAppState.js', () => ({ 34 | useAppState: () => ({ 35 | isEmpty: mockSessionStore.isEmpty, 36 | clearSession: mockSessionStore.clearSession, 37 | clearCache: mockSessionStore.clearCache 38 | }) 39 | })) 40 | 41 | // Mock child components 42 | vi.mock('@/components/FilterMenuComponent.vue', () => ({ 43 | default: { template: '
    Filter Menu
    ' } 44 | })) 45 | 46 | describe('HeaderPanel', () => { 47 | const mockAppMeta = { 48 | nameHtml: 'PureSuggest', 49 | subtitle: 'Literature discovery made easy' 50 | } 51 | 52 | const defaultStubs = { 53 | 'v-app-bar': { template: '
    ' }, 54 | 'v-app-bar-title': { template: '
    ' }, 55 | 'v-icon': { template: '' }, 56 | 'v-btn': { template: '' }, 57 | 'v-menu': { template: '
    ' }, 58 | 'v-list': { template: '
    ' }, 59 | 'v-list-item': { template: '
    ' }, 60 | KeywordMenuComponent: { template: '
    Boost Keywords
    ' }, 61 | FilterMenuComponent: { template: '
    Filter Menu
    ' }, 62 | SessionMenuComponent: { template: '
    Session Menu
    ' }, 63 | HeaderExternalLinks: { template: '' } 64 | } 65 | 66 | const createWrapper = (props = {}) => 67 | mount(HeaderPanel, { 68 | global: { 69 | provide: { appMeta: mockAppMeta }, 70 | stubs: defaultStubs 71 | }, 72 | ...props 73 | }) 74 | 75 | beforeEach(() => { 76 | vi.clearAllMocks() 77 | mockSessionStore.isEmpty = true 78 | mockSessionStore.selectedPublicationsCount = 0 79 | mockSessionStore.excludedPublicationsCount = 0 80 | }) 81 | 82 | it('renders without errors', () => { 83 | const wrapper = createWrapper() 84 | expect(wrapper.exists()).toBe(true) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tests/unit/bugs/author-year-merge-infinity.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest' 2 | 3 | import Author from '@/core/Author.js' 4 | 5 | vi.mock('@/constants/config.js', () => ({ 6 | SCORING: { 7 | FIRST_AUTHOR_BOOST: 2, 8 | NEW_PUBLICATION_BOOST: 1.5 9 | } 10 | })) 11 | 12 | describe('Bug: Author.mergeWith sets yearMin/yearMax to Infinity when both authors have undefined years', () => { 13 | let mockPublication 14 | 15 | beforeEach(() => { 16 | mockPublication = { 17 | doi: '10.1234/test', 18 | score: 10, 19 | year: undefined, // No year information 20 | isNew: false, 21 | boostKeywords: [], 22 | author: 'Smith, John', 23 | authorOrcid: 'Smith, John' 24 | } 25 | }) 26 | 27 | it('should keep yearMin as NaN when merging two authors with undefined years', () => { 28 | const author1 = new Author('Smith, John', 0, mockPublication) 29 | const author2 = new Author('Smith, John', 0, mockPublication) 30 | 31 | // Both authors have NaN years from publication without year 32 | expect(author1.yearMin).toBeNaN() 33 | expect(author2.yearMin).toBeNaN() 34 | 35 | author1.mergeWith(author2) 36 | 37 | // After merge, yearMin should remain undefined, not become Infinity 38 | expect(author1.yearMin).toBe(undefined) 39 | expect(author1.yearMin).not.toBe(Infinity) 40 | }) 41 | 42 | it('should keep yearMax as undefined when merging two authors with undefined years', () => { 43 | const author1 = new Author('Smith, John', 0, mockPublication) 44 | const author2 = new Author('Smith, John', 0, mockPublication) 45 | 46 | // Both authors have NaN years from publication without year 47 | expect(author1.yearMax).toBeNaN() 48 | expect(author2.yearMax).toBeNaN() 49 | 50 | author1.mergeWith(author2) 51 | 52 | // After merge, yearMax should remain undefined, not become -Infinity 53 | expect(author1.yearMax).toBe(undefined) 54 | expect(author1.yearMax).not.toBe(-Infinity) 55 | }) 56 | 57 | it('should correctly merge when one author has years and the other does not', () => { 58 | const mockPub2020 = { ...mockPublication, year: 2020 } 59 | const mockPubNoYear = { ...mockPublication, year: undefined } 60 | 61 | const author1 = new Author('Smith, John', 0, mockPub2020) 62 | const author2 = new Author('Smith, John', 0, mockPubNoYear) 63 | 64 | expect(author1.yearMin).toBe(2020) 65 | expect(author1.yearMax).toBe(2020) 66 | expect(author2.yearMin).toBeNaN() 67 | expect(author2.yearMax).toBeNaN() 68 | 69 | author1.mergeWith(author2) 70 | 71 | // When one has a year and the other doesn't, the valid year should be preserved 72 | expect(author1.yearMin).toBe(2020) 73 | expect(author1.yearMax).toBe(2020) 74 | }) 75 | 76 | it('should correctly compute min/max when both authors have different years', () => { 77 | const mockPub2020 = { ...mockPublication, year: 2020 } 78 | const mockPub2023 = { ...mockPublication, year: 2023 } 79 | 80 | const author1 = new Author('Smith, John', 0, mockPub2020) 81 | const author2 = new Author('Smith, John', 0, mockPub2023) 82 | 83 | author1.mergeWith(author2) 84 | 85 | // Should compute proper min/max 86 | expect(author1.yearMin).toBe(2020) 87 | expect(author1.yearMax).toBe(2023) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /tests/unit/integration/author-modal-workflow.test.js: -------------------------------------------------------------------------------- 1 | import { createPinia, setActivePinia } from 'pinia' 2 | import { describe, it, expect, beforeEach, vi } from 'vitest' 3 | 4 | import { useModalManager } from '@/composables/useModalManager.js' 5 | import { useAuthorStore } from '@/stores/author.js' 6 | import { useModalStore } from '@/stores/modal.js' 7 | import { useSessionStore } from '@/stores/session.js' 8 | 9 | describe('Author Modal Integration Workflow', () => { 10 | let pinia 11 | let modalStore 12 | let authorStore 13 | let sessionStore 14 | let modalManager 15 | 16 | beforeEach(() => { 17 | pinia = createPinia() 18 | setActivePinia(pinia) 19 | 20 | modalStore = useModalStore() 21 | authorStore = useAuthorStore() 22 | sessionStore = useSessionStore() 23 | modalManager = useModalManager() 24 | 25 | // Spy on the methods 26 | vi.spyOn(authorStore, 'computeSelectedPublicationsAuthors') 27 | vi.spyOn(sessionStore, 'updatePublicationScores') 28 | }) 29 | 30 | describe('Author Modal Opening', () => { 31 | it('should open modal and set author ID when provided', () => { 32 | modalManager.openAuthorModal('test-author-id') 33 | 34 | expect(modalStore.isAuthorModalDialogShown).toBe(true) 35 | expect(authorStore.activeAuthorId).toBe('test-author-id') 36 | }) 37 | 38 | it('should open modal without setting author ID when not provided', () => { 39 | modalManager.openAuthorModal() 40 | 41 | expect(modalStore.isAuthorModalDialogShown).toBe(true) 42 | expect(authorStore.activeAuthorId).toBeNull() 43 | }) 44 | }) 45 | 46 | describe('Session Loading Scenarios', () => { 47 | it('should identify publications that need score updates after session loading', () => { 48 | const publicationsAfterSessionLoad = [ 49 | { 50 | doi: '10.1234/test1', 51 | title: 'Test Publication 1', 52 | year: 2020, 53 | author: 'John Doe; Jane Smith', 54 | score: 0, // Default score - not yet computed 55 | boostKeywords: [], // Empty - not yet processed 56 | citationCount: undefined, // Session data doesn't include these yet 57 | referenceCount: undefined 58 | } 59 | ] 60 | 61 | // Test the logic that would be used by the modal component 62 | const needsUpdate = publicationsAfterSessionLoad.some( 63 | (pub) => 64 | pub.score === 0 && 65 | (!pub.boostKeywords || pub.boostKeywords.length === 0) && 66 | (pub.citationCount === undefined || pub.referenceCount === undefined) 67 | ) 68 | 69 | expect(needsUpdate).toBe(true) 70 | }) 71 | 72 | it('should not flag publications with legitimate zero scores as needing updates', () => { 73 | const publicationsWithLegitimateZeroScores = [ 74 | { 75 | doi: '10.1234/test1', 76 | score: 0, 77 | boostKeywords: ['machine learning'], 78 | citationCount: 0, 79 | referenceCount: 3 80 | } 81 | ] 82 | 83 | const needsUpdate = publicationsWithLegitimateZeroScores.some( 84 | (pub) => 85 | pub.score === 0 && 86 | (!pub.boostKeywords || pub.boostKeywords.length === 0) && 87 | (pub.citationCount === undefined || pub.referenceCount === undefined) 88 | ) 89 | 90 | expect(needsUpdate).toBe(false) 91 | }) 92 | }) 93 | }) -------------------------------------------------------------------------------- /src/core/PublicationSearch.js: -------------------------------------------------------------------------------- 1 | import Publication from './Publication.js' 2 | import { API_ENDPOINTS, API_PARAMS } from '../constants/config.js' 3 | import { cachedFetch } from '../lib/Cache.js' 4 | 5 | export default class PublicationSearch { 6 | constructor(query, provider = 'openalex') { 7 | this.query = query 8 | this.provider = provider 9 | } 10 | 11 | async execute() { 12 | const dois = [] 13 | const results = [] 14 | // removing whitespace (e.g., through line breaks) in DOIs 15 | this.query = this.query.replace(/(10\.\d+\/)\s?(\S{0,12})\s([^[])/g, '$1$2$3') 16 | // splitting query by characters that must (or partly: should) be encoded differently in DOIs or by typical prefixes 17 | // see: https://www.doi.org/doi_handbook/2_Numbering.html 18 | // "\{|\}" necessary to read DOIs from BibTeX 19 | this.query.split(/ |"|%|#|\?|\{|\}|doi:|doi.org\//).forEach((doi) => { 20 | // cutting characters that might be included in DOI, but very unlikely at the end 21 | doi = doi 22 | .trim() 23 | // Optimized with separate replace calls to avoid alternation backtracking 24 | // eslint-disable-next-line sonarjs/slow-regex 25 | .replace(/^[.,;]+/, '').replace(/[.,;]+$/, '') 26 | .replace('\\_', '_') 27 | if (doi.indexOf('10.') === 0 && !dois.includes(doi)) { 28 | dois.push(doi) 29 | const publication = new Publication(doi) 30 | publication.fetchData() 31 | results.push(publication) 32 | } 33 | }) 34 | if (dois.length) { 35 | console.log(`Identified ${results.length} DOI(s) in input; do not perform search.`) 36 | return { results, type: 'doi' } 37 | } 38 | 39 | console.log(`Searching for publications matching '${this.query}' using ${this.provider}.`) 40 | 41 | if (this.provider === 'openalex') { 42 | await this.searchOpenAlex(results) 43 | } else { 44 | await this.searchCrossRef(results) 45 | } 46 | 47 | return { results, type: 'search' } 48 | } 49 | 50 | async searchCrossRef(results) { 51 | const simplifiedQuery = this.query.replace(/\W+/g, '+').toLowerCase() 52 | await cachedFetch( 53 | `${API_ENDPOINTS.CROSSREF}?query=${simplifiedQuery}&mailto=${API_PARAMS.CROSSREF_EMAIL}&filter=${API_PARAMS.CROSSREF_FILTER}&sort=${API_PARAMS.CROSSREF_SORT}&order=desc`, 54 | (data) => { 55 | data.message.items 56 | .filter((item) => item.title) 57 | .forEach((item) => { 58 | const publication = new Publication(item.DOI) 59 | publication.fetchData() 60 | results.push(publication) 61 | }) 62 | } 63 | ) 64 | } 65 | 66 | async searchOpenAlex(results) { 67 | const simplifiedQuery = encodeURIComponent(this.query) 68 | await cachedFetch( 69 | `${API_ENDPOINTS.OPENALEX}?search=${simplifiedQuery}&filter=has_doi:true&per_page=20&mailto=${API_PARAMS.OPENALEX_EMAIL}`, 70 | (data) => { 71 | data.results 72 | .filter((item) => item.doi) 73 | .forEach((item) => { 74 | // Extract DOI from the full URL (OpenAlex returns it as https://doi.org/...) 75 | const doi = item.doi.replace('https://doi.org/', '') 76 | const publication = new Publication(doi) 77 | publication.fetchData() 78 | results.push(publication) 79 | }) 80 | } 81 | ) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/modal/ModalDialog.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 78 | 79 | 135 | -------------------------------------------------------------------------------- /tests/unit/components/NetworkVisComponentHover.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | 3 | import NetworkVisComponent from '@/components/NetworkVisComponent.vue' 4 | 5 | // Mock the complex dependencies 6 | vi.mock('d3', () => ({ 7 | select: vi.fn(() => ({ 8 | append: vi.fn(() => ({ attr: vi.fn(), style: vi.fn() })), 9 | attr: vi.fn(), 10 | style: vi.fn(), 11 | selectAll: vi.fn(() => ({ remove: vi.fn() })), 12 | on: vi.fn() 13 | })) 14 | })) 15 | 16 | // Mock child components 17 | vi.mock('@/components/NetworkControls.vue', () => ({ 18 | default: { name: 'NetworkControls', template: '
    ' } 19 | })) 20 | 21 | vi.mock('@/components/NetworkHeader.vue', () => ({ 22 | default: { name: 'NetworkHeader', template: '
    ' } 23 | })) 24 | 25 | vi.mock('@/components/NetworkPerformanceMonitor.vue', () => ({ 26 | default: { name: 'NetworkPerformanceMonitor', template: '
    ' } 27 | })) 28 | 29 | // Mock all the network utility modules 30 | vi.mock('@/utils/network/authorNodes.js', () => ({ 31 | initializeAuthorNodes: vi.fn(), 32 | updateAuthorNodes: vi.fn(), 33 | highlightAuthorPublications: vi.fn(), 34 | clearAuthorHighlight: vi.fn(), 35 | createAuthorLinks: vi.fn(), 36 | createAuthorNodes: vi.fn() 37 | })) 38 | 39 | vi.mock('@/utils/network/forces.js', () => ({ 40 | createForceSimulation: vi.fn(), 41 | initializeForces: vi.fn(), 42 | calculateYearX: vi.fn(), 43 | SIMULATION_ALPHA: 0.1, 44 | getNodeXPosition: vi.fn() 45 | })) 46 | 47 | vi.mock('@/utils/network/keywordNodes.js', () => ({ 48 | initializeKeywordNodes: vi.fn(), 49 | updateKeywordNodes: vi.fn(), 50 | releaseKeywordPosition: vi.fn(), 51 | highlightKeywordPublications: vi.fn(), 52 | clearKeywordHighlight: vi.fn(), 53 | createKeywordNodeDrag: vi.fn(), 54 | createKeywordLinks: vi.fn(), 55 | createKeywordNodes: vi.fn() 56 | })) 57 | 58 | vi.mock('@/utils/network/links.js', () => ({ 59 | updateNetworkLinks: vi.fn(), 60 | updateLinkProperties: vi.fn(), 61 | createCitationLinks: vi.fn() 62 | })) 63 | 64 | vi.mock('@/utils/network/publicationNodes.js', () => ({ 65 | initializePublicationNodes: vi.fn(), 66 | updatePublicationNodes: vi.fn(), 67 | createPublicationNodes: vi.fn() 68 | })) 69 | 70 | vi.mock('@/utils/network/yearLabels.js', () => ({ 71 | generateYearRange: vi.fn(), 72 | updateYearLabelContent: vi.fn(), 73 | updateYearLabelRects: vi.fn(), 74 | updateYearLabelVisibility: vi.fn() 75 | })) 76 | 77 | describe('NetworkVisComponent Hover Integration', () => { 78 | it('should have watcher configured for interfaceStore.hoveredPublication', () => { 79 | // Test that the watcher is configured correctly in the component definition 80 | const component = NetworkVisComponent 81 | const watchers = component.watch 82 | 83 | expect(watchers).toHaveProperty('interfaceStore.hoveredPublication') 84 | expect(typeof watchers['interfaceStore.hoveredPublication'].handler).toBe('function') 85 | }) 86 | 87 | it('watcher should call updatePublicationHighlighting', () => { 88 | // Create a mock component instance to test the watcher handler 89 | const mockComponent = { 90 | updatePublicationHighlighting: vi.fn() 91 | } 92 | 93 | // Get the watcher handler and call it 94 | const watcherHandler = NetworkVisComponent.watch['interfaceStore.hoveredPublication'].handler 95 | watcherHandler.call(mockComponent) 96 | 97 | expect(mockComponent.updatePublicationHighlighting).toHaveBeenCalled() 98 | }) 99 | }) -------------------------------------------------------------------------------- /tests/unit/components/PublicationTag.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { describe, it, expect } from 'vitest' 3 | 4 | import PublicationTag from '@/components/PublicationTag.vue' 5 | 6 | describe('PublicationTag', () => { 7 | const createWrapper = (props = {}) => { 8 | return mount(PublicationTag, { 9 | props, 10 | slots: { 11 | default: 'Test Tag' 12 | }, 13 | global: { 14 | stubs: { 15 | 'v-chip': { 16 | template: '
    ', 17 | props: ['color', 'size', 'prependIcon'] 18 | } 19 | } 20 | } 21 | }) 22 | } 23 | 24 | describe('Styling', () => { 25 | it('applies light theme (white bg, black text) when inactive', () => { 26 | const wrapper = createWrapper({ active: false }) 27 | 28 | expect(wrapper.find('.v-chip').classes()).toContain('has-background-white') 29 | expect(wrapper.find('.v-chip').classes()).toContain('has-text-black') 30 | expect(wrapper.find('.v-chip').classes()).not.toContain('has-background-dark') 31 | expect(wrapper.find('.v-chip').classes()).not.toContain('has-text-white') 32 | }) 33 | 34 | it('applies dark theme (dark bg, white text) when active', () => { 35 | const wrapper = createWrapper({ active: true }) 36 | 37 | expect(wrapper.find('.v-chip').classes()).toContain('has-background-dark') 38 | expect(wrapper.find('.v-chip').classes()).toContain('has-text-white') 39 | expect(wrapper.find('.v-chip').classes()).not.toContain('has-background-white') 40 | expect(wrapper.find('.v-chip').classes()).not.toContain('has-text-black') 41 | }) 42 | 43 | it('applies clickable class when clickable prop is true', () => { 44 | const wrapper = createWrapper({ clickable: true }) 45 | 46 | expect(wrapper.find('.v-chip').classes()).toContain('tag-clickable') 47 | }) 48 | 49 | it('does not apply clickable class when clickable prop is false', () => { 50 | const wrapper = createWrapper({ clickable: false }) 51 | 52 | expect(wrapper.find('.v-chip').classes()).not.toContain('tag-clickable') 53 | }) 54 | }) 55 | 56 | describe('Click Handling', () => { 57 | it('emits click event when clickable and clicked', async () => { 58 | const wrapper = createWrapper({ clickable: true }) 59 | 60 | await wrapper.find('.v-chip').trigger('click') 61 | 62 | expect(wrapper.emitted('click')).toBeTruthy() 63 | expect(wrapper.emitted('click')).toHaveLength(1) 64 | }) 65 | 66 | it('does not emit click event when not clickable and clicked', async () => { 67 | const wrapper = createWrapper({ clickable: false }) 68 | 69 | await wrapper.find('.v-chip').trigger('click') 70 | 71 | expect(wrapper.emitted('click')).toBeFalsy() 72 | }) 73 | }) 74 | 75 | describe('Icon Support', () => { 76 | it('renders with icon when icon prop is provided', () => { 77 | const wrapper = createWrapper({ icon: 'mdi-star' }) 78 | 79 | expect(wrapper.props('icon')).toBe('mdi-star') 80 | }) 81 | 82 | it('renders without icon when icon prop is not provided', () => { 83 | const wrapper = createWrapper() 84 | 85 | expect(wrapper.props('icon')).toBe('') 86 | }) 87 | }) 88 | 89 | describe('Content Rendering', () => { 90 | it('renders slot content correctly', () => { 91 | const wrapper = createWrapper() 92 | 93 | expect(wrapper.text()).toContain('Test Tag') 94 | }) 95 | }) 96 | }) 97 | -------------------------------------------------------------------------------- /src/components/modal/KeyboardControlsModalDialog.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 82 | 83 | 96 | -------------------------------------------------------------------------------- /tests/performance/utils.js: -------------------------------------------------------------------------------- 1 | export const PERFORMANCE_THRESHOLDS = { 2 | renderTime: 100, // ms for initial render 3 | memoryUsage: 50, // MB maximum 4 | scrollFPS: 30, // minimum FPS during scroll 5 | hoverDelay: 16, // ms maximum hover response 6 | rerenderTime: 50, // ms for reactive updates 7 | batchRenderTime: 500 // ms for rendering multiple components 8 | } 9 | 10 | export function measureRenderTime(mountFn) { 11 | const start = performance.now() 12 | const result = mountFn() 13 | const end = performance.now() 14 | return { result, renderTime: end - start } 15 | } 16 | 17 | export function measureMemoryUsage() { 18 | if (performance.memory) { 19 | return { 20 | used: performance.memory.usedJSHeapSize, 21 | total: performance.memory.totalJSHeapSize, 22 | limit: performance.memory.jsHeapSizeLimit, 23 | usedMB: Math.round(performance.memory.usedJSHeapSize / 1024 / 1024) 24 | } 25 | } 26 | return null 27 | } 28 | 29 | export function measureFPS(duration = 1000) { 30 | return new Promise((resolve) => { 31 | let frameCount = 0 32 | const startTime = performance.now() 33 | 34 | function countFrame() { 35 | frameCount++ 36 | const elapsed = performance.now() - startTime 37 | 38 | if (elapsed < duration) { 39 | requestAnimationFrame(countFrame) 40 | } else { 41 | const fps = Math.round((frameCount * 1000) / elapsed) 42 | resolve(fps) 43 | } 44 | } 45 | 46 | requestAnimationFrame(countFrame) 47 | }) 48 | } 49 | 50 | export function createMockPublication(id = 'test-doi', overrides = {}) { 51 | return { 52 | doi: id, 53 | title: 'Test Publication', 54 | author: 'Test Author', 55 | authorOrcidHtml: 'Test Author.', 56 | year: 2023, 57 | container: 'Test Journal', 58 | score: 5, 59 | scoreColor: '#ff0000', 60 | citationCount: 10, 61 | referenceCount: 5, 62 | referenceDois: ['ref1', 'ref2'], 63 | citationDois: ['cite1', 'cite2', 'cite3'], 64 | tooManyCitations: false, 65 | boostFactor: 2, 66 | boostMatches: 1, 67 | isActive: false, 68 | isSelected: false, 69 | isLinkedToActive: false, 70 | isRead: true, 71 | wasFetched: true, 72 | isHovered: false, 73 | isKeywordHovered: false, 74 | isAuthorHovered: false, 75 | ...overrides 76 | } 77 | } 78 | 79 | export function createMockPublications(count, baseProps = {}) { 80 | return Array.from({ length: count }, (_, i) => 81 | createMockPublication(`doi-${i}`, { 82 | ...baseProps, 83 | score: Math.floor(Math.random() * 10) + 1 84 | }) 85 | ) 86 | } 87 | 88 | export class PerformanceProfiler { 89 | constructor(name) { 90 | this.name = name 91 | this.startTime = null 92 | this.endTime = null 93 | this.markers = [] 94 | } 95 | 96 | start() { 97 | this.startTime = performance.now() 98 | performance.mark(`${this.name}-start`) 99 | return this 100 | } 101 | 102 | mark(label) { 103 | const time = performance.now() 104 | this.markers.push({ label, time: time - this.startTime }) 105 | performance.mark(`${this.name}-${label}`) 106 | return this 107 | } 108 | 109 | end() { 110 | this.endTime = performance.now() 111 | performance.mark(`${this.name}-end`) 112 | performance.measure(this.name, `${this.name}-start`, `${this.name}-end`) 113 | return this 114 | } 115 | 116 | getDuration() { 117 | return this.endTime - this.startTime 118 | } 119 | 120 | getMarkers() { 121 | return [...this.markers] 122 | } 123 | 124 | getReport() { 125 | return { 126 | name: this.name, 127 | duration: this.getDuration(), 128 | markers: this.getMarkers() 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/unit/Author.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeEach, vi } from 'vitest' 2 | 3 | import Author from '@/core/Author.js' 4 | 5 | // Mock constants 6 | vi.mock('@/constants/publication.js', () => ({ 7 | SCORING: { 8 | FIRST_AUTHOR_BOOST: 2, 9 | NEW_PUBLICATION_BOOST: 1.5 10 | } 11 | })) 12 | 13 | describe('Author Bug Regression Tests', () => { 14 | let mockPublication 15 | 16 | beforeEach(() => { 17 | mockPublication = { 18 | doi: '10.1234/test', 19 | score: 10, 20 | year: 2023, 21 | isNew: false, 22 | boostKeywords: ['machine learning', 'AI'], 23 | author: 'Smith, John; Doe, Jane; Johnson, Bob' 24 | } 25 | }) 26 | 27 | describe('nameToId unicode normalization - Bug Fix Regression', () => { 28 | it('should properly normalize Nordic characters', () => { 29 | expect(Author.nameToId('Müller, José')).toBe('muller, jose') 30 | expect(Author.nameToId('François, Éric')).toBe('francois, eric') 31 | expect(Author.nameToId('Øresund, Åse')).toBe('oresund, ase') // This was the bug - Ø/Å not normalized 32 | expect(Author.nameToId('Bjørk, Lærer')).toBe('bjork, laerer') 33 | }) 34 | 35 | it('should handle other extended Latin characters', () => { 36 | expect(Author.nameToId('Müller, Björn')).toBe('muller, bjorn') 37 | expect(Author.nameToId('Æneas, Þór')).toBe('aeneas, thor') 38 | }) 39 | }) 40 | 41 | describe('constructor null author handling - Bug Fix Regression', () => { 42 | it('should handle null publication author without crashing', () => { 43 | mockPublication.author = null 44 | expect(() => { 45 | const author = new Author('Smith, John', 0, mockPublication) 46 | author.setScoring(true, false, false) 47 | expect(author.coauthors).toEqual({}) 48 | }).not.toThrow() 49 | }) 50 | 51 | it('should handle undefined publication author without crashing', () => { 52 | mockPublication.author = undefined 53 | expect(() => { 54 | const author = new Author('Doe, Jane', 0, mockPublication) 55 | author.setScoring(true, false, false) 56 | expect(author.coauthors).toEqual({}) 57 | }).not.toThrow() 58 | }) 59 | }) 60 | 61 | describe('mergeWith year handling - Bug Fix Regression', () => { 62 | let author1, author2 63 | 64 | beforeEach(() => { 65 | author1 = new Author('Smith, John', 0, mockPublication) 66 | author1.setScoring(true, false, false) 67 | author2 = new Author('Smith, John', 1, mockPublication) 68 | author2.setScoring(true, false, false) 69 | }) 70 | 71 | it('should handle merging with undefined years without NaN', () => { 72 | author1.yearMin = 2020 73 | author1.yearMax = 2022 74 | author2.yearMin = undefined 75 | author2.yearMax = undefined 76 | 77 | author1.mergeWith(author2) 78 | 79 | // Should preserve valid years, not become NaN 80 | expect(author1.yearMin).toBe(2020) 81 | expect(author1.yearMax).toBe(2022) 82 | expect(Number.isNaN(author1.yearMin)).toBe(false) 83 | expect(Number.isNaN(author1.yearMax)).toBe(false) 84 | }) 85 | 86 | it('should handle both authors having undefined years', () => { 87 | author1.yearMin = undefined 88 | author1.yearMax = undefined 89 | author2.yearMin = undefined 90 | author2.yearMax = undefined 91 | 92 | author1.mergeWith(author2) 93 | 94 | // When both are undefined, should remain undefined (not Infinity/-Infinity) 95 | expect(author1.yearMin).toBe(undefined) 96 | expect(author1.yearMax).toBe(undefined) 97 | expect(Number.isNaN(author1.yearMin)).toBe(false) 98 | expect(Number.isNaN(author1.yearMax)).toBe(false) 99 | }) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /src/components/QuickAccessBar.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 97 | 130 | -------------------------------------------------------------------------------- /tests/unit/features/author-modal-pagination-logic.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | describe('Author Modal Lazy Loading Logic', () => { 4 | it('should implement pagination logic correctly', () => { 5 | // Simulate the core pagination logic from AuthorModalDialog 6 | const totalAuthors = 50 7 | let displayedAuthorCount = 20 8 | const authorBatchSize = 20 9 | 10 | // Initial state - should show first 20 authors 11 | expect(displayedAuthorCount).toBe(20) 12 | 13 | // Check if more authors are available 14 | const hasMoreAuthorsToShow = displayedAuthorCount < totalAuthors 15 | expect(hasMoreAuthorsToShow).toBe(true) 16 | 17 | // Simulate loading more authors 18 | displayedAuthorCount = Math.min(displayedAuthorCount + authorBatchSize, totalAuthors) 19 | 20 | // Should now show 40 authors 21 | expect(displayedAuthorCount).toBe(40) 22 | 23 | // Load more again 24 | displayedAuthorCount = Math.min(displayedAuthorCount + authorBatchSize, totalAuthors) 25 | 26 | // Should now show all 50 authors 27 | expect(displayedAuthorCount).toBe(50) 28 | 29 | // No more authors to show 30 | const noMoreAuthors = displayedAuthorCount >= totalAuthors 31 | expect(noMoreAuthors).toBe(true) 32 | }) 33 | 34 | it('should handle scroll threshold calculation correctly', () => { 35 | // Simulate scroll detection logic 36 | const mockScrollEvent = { 37 | target: { 38 | scrollTop: 800, // Current scroll position 39 | scrollHeight: 1000, // Total scrollable height 40 | clientHeight: 200 // Visible height 41 | } 42 | } 43 | 44 | const scrollThreshold = 0.8 // 80% 45 | 46 | // Calculate scroll percentage 47 | const { scrollTop, scrollHeight, clientHeight } = mockScrollEvent.target 48 | const scrollPercentage = scrollTop / (scrollHeight - clientHeight) 49 | 50 | // Should trigger load more at 80% scroll 51 | expect(scrollPercentage).toBe(1.0) // 800 / (1000 - 200) = 1.0 (100%) 52 | expect(scrollPercentage >= scrollThreshold).toBe(true) 53 | 54 | // Test with different scroll position (50%) 55 | const partialScroll = { 56 | target: { 57 | scrollTop: 400, 58 | scrollHeight: 1000, 59 | clientHeight: 200 60 | } 61 | } 62 | 63 | const partialPercentage = 64 | partialScroll.target.scrollTop / 65 | (partialScroll.target.scrollHeight - partialScroll.target.clientHeight) 66 | 67 | expect(partialPercentage).toBe(0.5) // 50% 68 | expect(partialPercentage >= scrollThreshold).toBe(false) 69 | }) 70 | 71 | it('should implement array slicing logic correctly', () => { 72 | // Mock author array 73 | const allAuthors = Array.from({ length: 50 }, (_, i) => ({ 74 | id: `author-${i + 1}`, 75 | name: `Author ${i + 1}`, 76 | score: 50 - i 77 | })) 78 | 79 | // Test pagination slicing 80 | let displayedAuthorCount = 20 81 | 82 | // Get displayed authors (first batch) 83 | let displayedAuthors = allAuthors.slice(0, displayedAuthorCount) 84 | expect(displayedAuthors).toHaveLength(20) 85 | expect(displayedAuthors[0].name).toBe('Author 1') 86 | expect(displayedAuthors[19].name).toBe('Author 20') 87 | 88 | // Load more authors 89 | displayedAuthorCount = 40 90 | displayedAuthors = allAuthors.slice(0, displayedAuthorCount) 91 | expect(displayedAuthors).toHaveLength(40) 92 | expect(displayedAuthors[39].name).toBe('Author 40') 93 | 94 | // Test single author view filtering 95 | const activeAuthorId = 'author-5' 96 | const singleAuthorView = allAuthors.filter((author) => author.id === activeAuthorId) 97 | expect(singleAuthorView).toHaveLength(1) 98 | expect(singleAuthorView[0].name).toBe('Author 5') 99 | }) 100 | }) 101 | -------------------------------------------------------------------------------- /tests/unit/components/NetworkPerformanceMonitor.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { describe, it, expect, beforeEach } from 'vitest' 3 | 4 | import NetworkPerformanceMonitor from '@/components/NetworkPerformanceMonitor.vue' 5 | 6 | describe('NetworkPerformanceMonitor', () => { 7 | let wrapper 8 | 9 | beforeEach(() => { 10 | wrapper = mount(NetworkPerformanceMonitor, { 11 | props: { 12 | show: true, 13 | isEmpty: false, 14 | nodeCount: 10, 15 | linkCount: 15, 16 | shouldSkipEarlyTicks: false, 17 | skipEarlyTicks: 10 18 | } 19 | }) 20 | }) 21 | 22 | it('renders performance metrics when show is true and not empty', () => { 23 | expect(wrapper.find('.fps-display').exists()).toBe(true) 24 | expect(wrapper.text()).toContain('FPS:') 25 | expect(wrapper.text()).toContain('Nodes: 10') 26 | expect(wrapper.text()).toContain('Links: 15') 27 | expect(wrapper.text()).toContain('DOM Updates:') 28 | expect(wrapper.text()).toContain('Skipped:') 29 | }) 30 | 31 | it('shows empty state message when isEmpty is true', async () => { 32 | await wrapper.setProps({ isEmpty: true }) 33 | 34 | expect(wrapper.text()).toContain('SIMULATION SKIPPED') 35 | expect(wrapper.text()).toContain('(Empty State)') 36 | expect(wrapper.text()).toContain('Network Cleared') 37 | }) 38 | 39 | it('does not render when show is false', async () => { 40 | await wrapper.setProps({ show: false }) 41 | 42 | expect(wrapper.find('.fps-display').exists()).toBe(false) 43 | }) 44 | 45 | it('shows skipping indicator during early tick skip period', async () => { 46 | // Increment tick counter and set props 47 | wrapper.vm.incrementTick() 48 | wrapper.vm.incrementTick() 49 | wrapper.vm.incrementTick() 50 | wrapper.vm.incrementTick() 51 | wrapper.vm.incrementTick() // tickCount = 5 52 | await wrapper.setProps({ shouldSkipEarlyTicks: true, skipEarlyTicks: 10 }) 53 | await wrapper.vm.$nextTick() 54 | 55 | expect(wrapper.text()).toContain('Tick: 5 (skipping)') 56 | }) 57 | 58 | it('tracks FPS correctly', () => { 59 | wrapper.vm.trackFps() 60 | 61 | // FPS tracker should be working (exact value depends on timing) 62 | expect(wrapper.vm.fpsTracker).toBeDefined() 63 | }) 64 | 65 | it('increments tick counter', () => { 66 | const initialTick = wrapper.vm.tickCount 67 | wrapper.vm.incrementTick() 68 | 69 | expect(wrapper.vm.tickCount).toBe(initialTick + 1) 70 | }) 71 | 72 | it('records DOM update metrics', () => { 73 | wrapper.vm.recordDomUpdate(5, 8) 74 | 75 | expect(wrapper.vm.domUpdateCount).toBe(1) 76 | expect(wrapper.vm.lastNodeUpdateCount).toBe(5) 77 | expect(wrapper.vm.lastLinkUpdateCount).toBe(8) 78 | }) 79 | 80 | it('records skipped updates', () => { 81 | wrapper.vm.recordSkippedUpdate() 82 | 83 | expect(wrapper.vm.skippedUpdateCount).toBe(1) 84 | expect(wrapper.vm.lastNodeUpdateCount).toBe(0) 85 | expect(wrapper.vm.lastLinkUpdateCount).toBe(0) 86 | }) 87 | 88 | it('resets all metrics', () => { 89 | // Set some values first by calling methods 90 | wrapper.vm.incrementTick() 91 | wrapper.vm.incrementTick() 92 | wrapper.vm.incrementTick() 93 | wrapper.vm.incrementTick() 94 | wrapper.vm.incrementTick() // tickCount = 5 95 | wrapper.vm.recordDomUpdate(4, 6) // domUpdateCount = 1, lastNodeUpdateCount = 4, lastLinkUpdateCount = 6 96 | wrapper.vm.recordSkippedUpdate() // skippedUpdateCount = 1 97 | wrapper.vm.recordSkippedUpdate() // skippedUpdateCount = 2 98 | 99 | wrapper.vm.resetMetrics() 100 | 101 | expect(wrapper.vm.tickCount).toBe(0) 102 | expect(wrapper.vm.domUpdateCount).toBe(0) 103 | expect(wrapper.vm.skippedUpdateCount).toBe(0) 104 | expect(wrapper.vm.lastNodeUpdateCount).toBe(0) 105 | expect(wrapper.vm.lastLinkUpdateCount).toBe(0) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /src/stores/interface.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | export const useInterfaceStore = defineStore('interface', { 4 | state: () => { 5 | return { 6 | isLoading: false, 7 | loadingMessage: '', 8 | errorToast: { 9 | message: '', 10 | isShown: false, 11 | type: '', 12 | duration: 0 13 | }, 14 | isNetworkExpanded: false, 15 | isNetworkCollapsed: false, 16 | isNetworkClusters: true, 17 | showPerformancePanel: false, 18 | // Network replot trigger (incremented to notify NetworkVisComponent to replot) 19 | networkReplotTrigger: 0, 20 | isMobile: true, 21 | isWideScreen: false, 22 | isFilterMenuOpen: false, 23 | // Hover state management 24 | hoveredPublication: null, // DOI of currently hovered publication 25 | // Keyboard navigation direction tracking 26 | lastNavigationDirection: null // 'up' or 'down' - for scroll behavior 27 | } 28 | }, 29 | getters: {}, 30 | actions: { 31 | clear() { 32 | this.isNetworkExpanded = false 33 | this.isNetworkCollapsed = false 34 | this.isNetworkClusters = true 35 | window.scrollTo(0, 0) 36 | }, 37 | 38 | checkMobile() { 39 | this.isMobile = window.innerWidth < 1023 40 | this.isWideScreen = window.innerWidth >= 2400 41 | }, 42 | 43 | startLoading() { 44 | this.isLoading = true 45 | }, 46 | 47 | endLoading() { 48 | this.isLoading = false 49 | this.loadingMessage = '' 50 | }, 51 | 52 | showErrorMessage(errorMessage) { 53 | console.error(errorMessage) 54 | this.errorToast = { 55 | isShown: true, 56 | message: errorMessage 57 | } 58 | }, 59 | 60 | activatePublicationComponent(publicationComponent, navigationDirection = null) { 61 | // Store navigation direction for scrolling behavior 62 | this.lastNavigationDirection = navigationDirection; 63 | if (publicationComponent && typeof publicationComponent.focus === 'function') { 64 | publicationComponent.focus(); 65 | 66 | // Radical solution: ALWAYS center during keyboard navigation 67 | if (navigationDirection) { 68 | this.centerPublication(publicationComponent); 69 | } 70 | } 71 | }, 72 | 73 | centerPublication(publicationComponent) { 74 | // Radical approach: Always center the publication, no conditions 75 | // Wait briefly for focus/activation to complete, then center 76 | setTimeout(() => { 77 | publicationComponent.scrollIntoView({ 78 | behavior: "smooth", 79 | block: "center", 80 | inline: "nearest" 81 | }); 82 | }, 50); // Minimal delay just for focus completion 83 | }, 84 | 85 | triggerNetworkReplot() { 86 | // Increment trigger counter to notify NetworkVisComponent to replot 87 | this.networkReplotTrigger++ 88 | }, 89 | 90 | openFilterMenu() { 91 | if (this.isFilterMenuOpen) { 92 | this.closeFilterMenu() 93 | return false 94 | } 95 | this.isFilterMenuOpen = true 96 | return true 97 | }, 98 | 99 | closeFilterMenu() { 100 | this.isFilterMenuOpen = false 101 | }, 102 | 103 | setFilterMenuState(isOpen) { 104 | this.isFilterMenuOpen = isOpen 105 | }, 106 | 107 | togglePerformancePanel() { 108 | this.showPerformancePanel = !this.showPerformancePanel 109 | }, 110 | 111 | collapseNetwork() { 112 | this.isNetworkExpanded = false 113 | this.isNetworkCollapsed = true 114 | }, 115 | 116 | expandNetwork() { 117 | this.isNetworkExpanded = true 118 | this.isNetworkCollapsed = false 119 | }, 120 | 121 | restoreNetwork() { 122 | this.isNetworkExpanded = false 123 | this.isNetworkCollapsed = false 124 | }, 125 | 126 | setHoveredPublication(publication) { 127 | // Set the DOI of the hovered publication, or null if no publication is hovered 128 | this.hoveredPublication = publication?.doi || null 129 | } 130 | } 131 | }) -------------------------------------------------------------------------------- /src/components/modal/ExcludedModalDialog.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 112 | 113 | 116 | -------------------------------------------------------------------------------- /src/components/NetworkPerformanceMonitor.vue: -------------------------------------------------------------------------------- 1 | 116 | 117 | 143 | 144 | 162 | -------------------------------------------------------------------------------- /src/utils/network/links.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Network Link Management 3 | * 4 | * This module handles the creation, initialization, and updating of links 5 | * in the network visualization. Links represent relationships between nodes 6 | * including citations, keywords, and authors. 7 | */ 8 | 9 | /** 10 | * Create citation links between publications 11 | */ 12 | export function createCitationLinks(selectedPublications, isSelectedFn, doiToIndex) { 13 | const links = [] 14 | const linkSet = new Set() // Track unique links to avoid duplicates 15 | 16 | selectedPublications.forEach((publication) => { 17 | if (publication.doi in doiToIndex) { 18 | // Create links for citations (papers this publication cites) 19 | publication.citationDois.forEach((citationDoi) => { 20 | if (citationDoi in doiToIndex) { 21 | const linkKey = `${citationDoi}→${publication.doi}` 22 | if (!linkSet.has(linkKey)) { 23 | linkSet.add(linkKey) 24 | links.push({ 25 | source: citationDoi, 26 | target: publication.doi, 27 | type: 'citation', 28 | internal: isSelectedFn(citationDoi) 29 | }) 30 | } 31 | } 32 | }) 33 | 34 | // Create links for references (papers that cite this publication) 35 | publication.referenceDois.forEach((referenceDoi) => { 36 | if (referenceDoi in doiToIndex) { 37 | const linkKey = `${publication.doi}→${referenceDoi}` 38 | if (!linkSet.has(linkKey)) { 39 | linkSet.add(linkKey) 40 | links.push({ 41 | source: publication.doi, 42 | target: referenceDoi, 43 | type: 'citation', 44 | internal: isSelectedFn(referenceDoi) 45 | }) 46 | } 47 | } 48 | }) 49 | } 50 | }) 51 | 52 | return links 53 | } 54 | 55 | /** 56 | * Update link DOM elements with data binding 57 | */ 58 | // @indirection-reviewed: meaningful-abstraction - D3 data binding abstraction adds clarity 59 | export function updateNetworkLinks(linkSelection, links) { 60 | return linkSelection.data(links, (d) => [d.source, d.target]).join('path') 61 | } 62 | 63 | /** 64 | * Calculate link path geometry for different link types 65 | */ 66 | export function calculateLinkPath(d, nodeX) { 67 | const dx = nodeX(d.target) - nodeX(d.source) 68 | const dy = d.target.y - d.source.y 69 | 70 | // Curved link for citations 71 | if (d.type === 'citation') { 72 | const dr = (dx * dx + dy * dy)**0.6 73 | return `M${nodeX(d.target)},${d.target.y}A${dr},${dr} 0 0,1 ${nodeX(d.source)},${d.source.y}` 74 | } 75 | 76 | // Tapered links for keywords and authors: 77 | // Drawing a triangle as part of a circle segment with its center at the target node 78 | const r = Math.sqrt(dx**2 + dy**2) 79 | const alpha = Math.acos(dx / r) 80 | const beta = 2 / r 81 | const x1 = r * Math.cos(alpha + beta) 82 | let y1 = r * Math.sin(alpha + beta) 83 | const x2 = r * Math.cos(alpha - beta) 84 | let y2 = r * Math.sin(alpha - beta) 85 | 86 | if (d.source.y > d.target.y) { 87 | y1 = -y1 88 | y2 = -y2 89 | } 90 | 91 | return `M${nodeX(d.target) - x1},${d.target.y - y1} 92 | L${nodeX(d.target)},${d.target.y} 93 | L${nodeX(d.target) - x2},${d.target.y - y2}` 94 | } 95 | 96 | /** 97 | * Calculate CSS classes for link styling based on state 98 | */ 99 | export function calculateLinkClasses(d, activePublication) { 100 | const classes = [d.type] 101 | 102 | if (d.type === 'citation') { 103 | if (activePublication) { 104 | if (d.source.publication.isActive || d.target.publication.isActive) { 105 | classes.push('active') 106 | } else { 107 | classes.push('non-active') 108 | } 109 | } 110 | if (!(d.source.publication.isSelected && d.target.publication.isSelected)) { 111 | classes.push('external') 112 | } 113 | } else if (d.type === 'keyword' || d.type === 'author') { 114 | if (activePublication) { 115 | if (d.target.publication.isActive) { 116 | classes.push('active') 117 | } else { 118 | classes.push('non-active') 119 | } 120 | } 121 | } 122 | 123 | return classes.join(' ') 124 | } 125 | 126 | -------------------------------------------------------------------------------- /src/components/NetworkHeader.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 113 | -------------------------------------------------------------------------------- /.claude/commands/address-issue.md: -------------------------------------------------------------------------------- 1 | # Address Issue Command 2 | 3 | **Usage**: `\address-issue [optional-comments]` 4 | 5 | **Description**: Systematically addresses a GitHub issue following a test-driven development approach with proper analysis, implementation, testing, and PR creation. 6 | 7 | ## Parameters 8 | 9 | - `issue-number` (required): The GitHub issue number to address 10 | - `optional-comments` (optional): Additional context or specific requirements for the fix 11 | 12 | ## Procedure 13 | 14 | This command follows a systematic 6-step approach to address GitHub issues: 15 | 16 | ### 1. **Issue Analysis & Branch Creation** 17 | 18 | - Fetch and analyze the GitHub issue using `gh issue view ` 19 | - Understand the problem description and expected behavior 20 | - Create a descriptive feature branch: `fix--` 21 | - Set up todo tracking to manage the workflow 22 | 23 | ### 2. **Root Cause Investigation** 24 | 25 | - Search codebase to locate relevant files and components 26 | - Understand the current implementation and identify why the issue occurs 27 | - Use debugging techniques (reading code, checking test failures, analyzing error patterns) 28 | - Document the root cause(s) - there may be multiple underlying problems 29 | 30 | ### 3. **Test-Driven Development** 31 | 32 | - **First**: Write failing tests that reproduce the issue 33 | - Ensure tests actually fail with the current buggy code 34 | - Cover edge cases and error conditions 35 | - Write tests for both the main functionality and error handling scenarios 36 | 37 | ### 4. **Implementation & Fix** 38 | 39 | - Implement the minimal fix that addresses the root cause 40 | - Follow existing code patterns and conventions 41 | - Handle edge cases gracefully with proper error handling 42 | - Ensure the fix is robust and doesn't introduce new issues 43 | 44 | ### 5. **Verification & Testing** 45 | 46 | - Run the new tests to verify they now pass 47 | - Run the full test suite to ensure no regressions 48 | - Run linting to maintain code quality 49 | - Test edge cases and error conditions 50 | - Verify the fix works as intended 51 | 52 | ### 6. **Commit & Pull Request** 53 | 54 | - Stage and commit changes with a descriptive commit message 55 | - Push the branch to the remote repository 56 | - Create a comprehensive pull request with: 57 | - Clear summary of changes 58 | - Root cause analysis explanation 59 | - Technical implementation details 60 | - Test plan and verification steps 61 | - Reference the original issue with "Fixes #" 62 | - Switch back to main branch after PR creation 63 | 64 | ## Best Practices Followed 65 | 66 | ### **Code Quality** 67 | 68 | - Follow existing code conventions and patterns 69 | - Write minimal, focused fixes that address the specific issue 70 | - Include proper error handling for edge cases 71 | - Maintain backward compatibility 72 | 73 | ### **Testing Strategy** 74 | 75 | - Write tests that would fail without the fix 76 | - Cover both happy path and edge cases 77 | - Ensure tests are maintainable and clearly describe the expected behavior 78 | - Update existing tests if the fix changes behavior 79 | 80 | ### **Documentation** 81 | 82 | - Write clear commit messages that explain the "why" not just the "what" 83 | - Create detailed PR descriptions with root cause analysis 84 | - Include test plans and verification steps 85 | - Reference the original issue for traceability 86 | 87 | ### **Process** 88 | 89 | - Use todo tracking to maintain organized workflow 90 | - Verify all tests pass before committing 91 | - Run code quality checks (linting, type checking) 92 | - Create focused, single-purpose commits 93 | - Follow the project's branching and PR conventions 94 | 95 | ## Example Usage 96 | 97 | ```bash 98 | \address-issue 525 99 | ``` 100 | 101 | ```bash 102 | \address-issue 525 "Focus specifically on keyboard navigation issues" 103 | ``` 104 | 105 | ## Expected Outcomes 106 | 107 | After running this command, you should have: 108 | 109 | - ✅ A thorough understanding of the issue and its root cause 110 | - ✅ A feature branch with the implemented fix 111 | - ✅ Comprehensive test coverage for the fix 112 | - ✅ All tests passing (including existing ones) 113 | - ✅ A pull request ready for review 114 | - ✅ Proper documentation and traceability 115 | 116 | This systematic approach ensures consistent, high-quality issue resolution that follows software engineering best practices and maintains code quality standards. 117 | -------------------------------------------------------------------------------- /tests/utils/d3-mocks.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | 3 | /** 4 | * Shared D3.js mock utilities for network visualization tests 5 | * Reduces duplication across multiple test files 6 | */ 7 | 8 | /** 9 | * Creates a mock D3 selection with chainable methods 10 | * @returns {Object} Mock selection object 11 | */ 12 | export const createMockSelection = () => { 13 | const mockData = [] 14 | return { 15 | append: vi.fn(() => createMockSelection()), 16 | attr: vi.fn(() => createMockSelection()), 17 | select: vi.fn(() => createMockSelection()), 18 | selectAll: vi.fn(() => createMockSelection()), 19 | call: vi.fn(() => createMockSelection()), 20 | data: vi.fn((data) => { 21 | if (data) { 22 | // Store the data and return an object with map function for D3 data binding 23 | return { 24 | map: vi.fn((fn) => data.map(fn)), 25 | join: vi.fn(() => createMockSelection()), 26 | enter: vi.fn(() => createMockSelection()), 27 | exit: vi.fn(() => createMockSelection()), 28 | remove: vi.fn(() => createMockSelection()) 29 | } 30 | } 31 | return mockData 32 | }), 33 | join: vi.fn(() => createMockSelection()), 34 | enter: vi.fn(() => createMockSelection()), 35 | exit: vi.fn(() => createMockSelection()), 36 | remove: vi.fn(() => createMockSelection()), 37 | merge: vi.fn(() => createMockSelection()), 38 | style: vi.fn(() => createMockSelection()), 39 | text: vi.fn(() => createMockSelection()), 40 | on: vi.fn(() => createMockSelection()), 41 | classed: vi.fn(() => createMockSelection()), 42 | filter: vi.fn((filterFn) => { 43 | // For testing, we'll return selections based on the filter function 44 | if (typeof filterFn === 'function') { 45 | // Mock some test data to filter 46 | const testData = [ 47 | { type: 'publication', id: 'pub1' }, 48 | { type: 'author', id: 'author1', author: { name: 'Test Author' } }, 49 | { type: 'keyword', id: 'keyword1' } 50 | ] 51 | const filteredData = testData.filter(filterFn) 52 | return { 53 | ...createMockSelection(), 54 | data: () => filteredData 55 | } 56 | } 57 | return createMockSelection() 58 | }), 59 | node: vi.fn(() => ({ getBoundingClientRect: () => ({ x: 0, y: 0, width: 100, height: 100 }) })), 60 | nodes: vi.fn(() => []) 61 | } 62 | } 63 | 64 | /** 65 | * Creates a mock D3 force for physics simulations 66 | * @returns {Object} Mock force object 67 | */ 68 | export const createMockForce = () => ({ 69 | links: vi.fn(() => createMockForce()), 70 | id: vi.fn(() => createMockForce()), 71 | distance: vi.fn(() => createMockForce()), 72 | strength: vi.fn(() => createMockForce()) 73 | }) 74 | 75 | /** 76 | * Creates a mock D3 force simulation 77 | * @returns {Object} Mock simulation object 78 | */ 79 | export const createMockSimulation = () => ({ 80 | alphaDecay: vi.fn(() => createMockSimulation()), 81 | alphaMin: vi.fn(() => createMockSimulation()), 82 | nodes: vi.fn(() => createMockSimulation()), 83 | force: vi.fn(() => createMockForce()), 84 | alpha: vi.fn(() => createMockSimulation()), 85 | restart: vi.fn(() => createMockSimulation()), 86 | stop: vi.fn(() => createMockSimulation()), 87 | on: vi.fn(() => createMockSimulation()) 88 | }) 89 | 90 | /** 91 | * Creates a complete D3.js module mock 92 | * Use with vi.mock('d3', () => createD3Mock()) 93 | * @returns {Object} Complete D3 module mock 94 | */ 95 | export const createD3Mock = () => ({ 96 | select: vi.fn(() => createMockSelection()), 97 | selectAll: vi.fn(() => createMockSelection()), 98 | zoom: vi.fn(() => ({ 99 | on: vi.fn(() => createMockSelection()) 100 | })), 101 | zoomTransform: vi.fn(() => ({ k: 1, x: 0, y: 0 })), 102 | drag: vi.fn(() => { 103 | const mockDrag = { 104 | on: vi.fn(() => mockDrag) 105 | } 106 | return mockDrag 107 | }), 108 | forceSimulation: vi.fn(() => createMockSimulation()), 109 | forceLink: vi.fn(() => ({ 110 | id: vi.fn(() => ({ 111 | distance: vi.fn(() => ({ 112 | strength: vi.fn(() => ({})) 113 | })) 114 | })) 115 | })), 116 | forceManyBody: vi.fn(() => ({ 117 | strength: vi.fn(() => ({})) 118 | })), 119 | forceX: vi.fn(() => ({ 120 | x: vi.fn(() => ({ 121 | strength: vi.fn(() => ({})) 122 | })) 123 | })), 124 | forceY: vi.fn(() => ({ 125 | y: vi.fn(() => ({ 126 | strength: vi.fn(() => ({})) 127 | })) 128 | })) 129 | }) 130 | 131 | -------------------------------------------------------------------------------- /src/composables/useModalManager.js: -------------------------------------------------------------------------------- 1 | import { useAuthorStore } from '@/stores/author.js' 2 | import { useModalStore } from '@/stores/modal.js' 3 | import { useSessionStore } from '@/stores/session.js' 4 | import { findAllKeywordMatches, highlightTitle, parseUniqueBoostKeywords } from '@/utils/scoringUtils.js' 5 | 6 | /** 7 | * Composable for managing modal dialogs and their associated actions 8 | * Centralizes all modal-related functionality and provides a clean API 9 | * for components to interact with modals without directly accessing stores 10 | */ 11 | export function useModalManager() { 12 | const modalStore = useModalStore() 13 | const authorStore = useAuthorStore() 14 | const sessionStore = useSessionStore() 15 | 16 | /** 17 | * Opens the search modal dialog 18 | * @param {string} [query] - Optional initial search query 19 | */ 20 | const openSearchModal = (query) => { 21 | modalStore.searchQuery = query || '' 22 | modalStore.isSearchModalDialogShown = true 23 | } 24 | 25 | /** 26 | * Opens the author modal dialog 27 | * @param {string} [authorId] - Optional author ID to set as active 28 | */ 29 | const openAuthorModal = (authorId) => { 30 | modalStore.isAuthorModalDialogShown = true 31 | 32 | // If authorId is provided, set the active author 33 | if (authorId) { 34 | authorStore.setActiveAuthor(authorId) 35 | } 36 | } 37 | 38 | /** 39 | * Opens the excluded publications modal dialog 40 | */ 41 | const openExcludedModal = () => { 42 | modalStore.isExcludedModalDialogShown = true 43 | } 44 | 45 | /** 46 | * Opens the queue modal dialog 47 | */ 48 | const openQueueModal = () => { 49 | modalStore.isQueueModalDialogShown = true 50 | } 51 | 52 | /** 53 | * Opens the about modal dialog 54 | */ 55 | const openAboutModal = () => { 56 | modalStore.isAboutModalDialogShown = true 57 | } 58 | 59 | /** 60 | * Opens the keyboard controls modal dialog 61 | */ 62 | const openKeyboardControlsModal = () => { 63 | modalStore.isKeyboardControlsModalDialogShown = true 64 | } 65 | 66 | /** 67 | * Opens the share session modal dialog 68 | */ 69 | const openShareSessionModal = () => { 70 | modalStore.isShareSessionModalDialogShown = true 71 | } 72 | 73 | /** 74 | * Shows a confirmation dialog 75 | * @param {string} message - The confirmation message (supports HTML) 76 | * @param {Function} confirmAction - The action to execute on confirmation 77 | * @param {string} [title='Confirm'] - The dialog title 78 | */ 79 | const showConfirmDialog = (message, confirmAction, title = 'Confirm') => { 80 | modalStore.confirmDialog = { 81 | message, 82 | action: confirmAction, 83 | isShown: true, 84 | title 85 | } 86 | } 87 | 88 | /** 89 | * Shows an information dialog (typically used for abstracts) 90 | * @param {Object} publication - The publication object with abstract 91 | */ 92 | const showAbstract = (publication) => { 93 | // Get boost keywords and apply highlighting to abstract 94 | // Unlike titles, abstracts highlight ALL occurrences of ALL keywords and alternatives 95 | const uniqueBoostKeywords = parseUniqueBoostKeywords(sessionStore.boostKeywordString) 96 | const matches = findAllKeywordMatches(publication.abstract || '', uniqueBoostKeywords) 97 | const highlightedAbstract = highlightTitle(publication.abstract || '', matches) 98 | 99 | modalStore.infoDialog = { 100 | title: 'Abstract', 101 | message: `
    ${highlightedAbstract}
    `, 102 | isShown: true 103 | } 104 | } 105 | 106 | /** 107 | * Closes all modal dialogs 108 | */ 109 | const closeAllModals = () => { 110 | modalStore.isSearchModalDialogShown = false 111 | modalStore.isAuthorModalDialogShown = false 112 | modalStore.isExcludedModalDialogShown = false 113 | modalStore.isQueueModalDialogShown = false 114 | modalStore.isAboutModalDialogShown = false 115 | modalStore.isKeyboardControlsModalDialogShown = false 116 | modalStore.isShareSessionModalDialogShown = false 117 | modalStore.confirmDialog.isShown = false 118 | modalStore.infoDialog.isShown = false 119 | } 120 | 121 | return { 122 | // Modal opening functions 123 | openSearchModal, 124 | openAuthorModal, 125 | openExcludedModal, 126 | openQueueModal, 127 | openAboutModal, 128 | openKeyboardControlsModal, 129 | openShareSessionModal, 130 | 131 | // Dialog functions 132 | showConfirmDialog, 133 | showAbstract, 134 | 135 | // Utility functions 136 | closeAllModals, 137 | 138 | // Expose computed properties from modal store for convenience 139 | isAnyOverlayShown: modalStore.isAnyOverlayShown 140 | } 141 | } -------------------------------------------------------------------------------- /.claude/commands/find-bug.md: -------------------------------------------------------------------------------- 1 | # Find Bug Command 2 | 3 | **Usage**: `/find-bug [optional-focus-area]` 4 | 5 | **Description**: Deep analysis to discover real bugs in the codebase through systematic examination, edge case analysis, and test-driven verification. 6 | 7 | ## Parameters 8 | 9 | - `optional-focus-area` (optional): Specific component, feature, or file to focus analysis on 10 | 11 | ## Procedure 12 | 13 | This command follows a systematic approach to discover and verify real bugs: 14 | 15 | ### 1. **Codebase Analysis** 16 | 17 | - Identify critical code paths and complex logic areas 18 | - Review recent changes and refactorings that might introduce regressions 19 | - Analyze error-prone patterns: state management, async operations, edge cases, data transformations 20 | - Focus on components with limited test coverage 21 | - Look for boundary conditions and validation gaps 22 | 23 | ### 2. **Deep Logic Inspection** 24 | 25 | - Examine conditional logic for missing branches or incorrect conditions 26 | - Check for race conditions in async code 27 | - Identify off-by-one errors, null/undefined handling gaps 28 | - Review data transformation pipelines for edge cases 29 | - Analyze component lifecycle and state update sequences 30 | - Look for inconsistent error handling 31 | - Check for memory leaks or performance issues 32 | 33 | ### 3. **Test-Driven Bug Verification** 34 | 35 | - **Write failing tests first** to reproduce suspected bugs 36 | - Create tests for edge cases that might not be handled 37 | - Test boundary conditions (empty arrays, null values, extreme inputs) 38 | - Test error scenarios and exception handling 39 | - Verify tests actually fail before fixing 40 | - Ensure tests demonstrate the real issue 41 | 42 | ### 4. **Root Cause Analysis** 43 | 44 | - Confirm the bug is real and reproducible 45 | - Document why the bug occurs (logic error, missing validation, race condition, etc.) 46 | - Assess impact and severity 47 | - Identify related code that might have similar issues 48 | 49 | ### 5. **Fix Implementation** 50 | 51 | - Implement minimal fix that addresses root cause 52 | - Handle edge cases properly 53 | - Add defensive checks where needed 54 | - Follow existing code patterns 55 | - Ensure backward compatibility 56 | 57 | ### 6. **Verification & Testing** 58 | 59 | - Run new tests to verify they now pass 60 | - Run full test suite to ensure no regressions 61 | - Run linting and type checking 62 | - Test manually if UI changes involved 63 | 64 | ## Analysis Focus Areas 65 | 66 | ### **High-Priority Targets** 67 | 68 | - State management logic (Pinia stores) 69 | - Data transformation functions 70 | - API integration and error handling 71 | - Citation/reference processing algorithms 72 | - Filter and search logic 73 | - Network visualization calculations 74 | - Publication scoring and ranking 75 | 76 | ### **Common Bug Patterns to Check** 77 | 78 | - Unhandled null/undefined values 79 | - Array operations on empty arrays 80 | - Async race conditions 81 | - Missing error boundaries 82 | - Incorrect conditional logic 83 | - Off-by-one errors in loops/pagination 84 | - State mutations vs immutability 85 | - Event listener cleanup 86 | - Cache invalidation issues 87 | 88 | ### **Edge Cases to Verify** 89 | 90 | - Empty datasets (no publications, no citations) 91 | - Single-item collections 92 | - Maximum load scenarios 93 | - Rapid user interactions 94 | - Network failures and retries 95 | - Invalid or malformed data 96 | - Concurrent operations 97 | 98 | ## Example Usage 99 | 100 | ```bash 101 | /find-bug 102 | ``` 103 | 104 | ```bash 105 | /find-bug FilterMenuComponent 106 | ``` 107 | 108 | ```bash 109 | /find-bug citation processing 110 | ``` 111 | 112 | ## Expected Outcomes 113 | 114 | After running this command, you should have: 115 | 116 | - ✅ Identified at least one real, reproducible bug 117 | - ✅ Root cause analysis explaining why the bug exists 118 | - ✅ Failing tests that demonstrate the bug 119 | - ✅ Implementation that fixes the bug 120 | - ✅ All tests passing including new ones 121 | - ✅ No regressions in existing functionality 122 | - ✅ Documentation of the bug and fix 123 | 124 | ## Success Criteria 125 | 126 | A bug is considered **real** if: 127 | 128 | 1. It causes incorrect behavior or errors in real usage scenarios 129 | 2. It can be reproduced consistently 130 | 3. It has a clear root cause in the code 131 | 4. It impacts functionality, not just style preferences 132 | 5. Tests can demonstrate the failure and success states 133 | 134 | ## Notes 135 | 136 | - Focus on finding **real bugs** that impact functionality, not theoretical issues 137 | - Prioritize bugs that users would encounter in normal usage 138 | - Look for bugs in undertested areas 139 | - Consider performance bugs and memory leaks 140 | - Think about mobile/responsive issues 141 | - Check accessibility concerns 142 | -------------------------------------------------------------------------------- /src/utils/network/authorNodes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Author Node Management 3 | * 4 | * This module handles the creation, initialization, and updating of author nodes 5 | * in the network visualization. Author nodes represent research authors and are 6 | * displayed as circular nodes with author initials. 7 | */ 8 | 9 | import tippy from 'tippy.js' 10 | 11 | /** 12 | * Create author node data from filtered authors 13 | */ 14 | export function createAuthorNodes(filteredAuthors, publications) { 15 | const nodes = [] 16 | const displayedDois = new Set(publications.map((pub) => pub.doi)) 17 | 18 | filteredAuthors.forEach((author) => { 19 | // Check if this author has any publications in the displayed set 20 | const hasDisplayedPublications = author.publicationDois.some((doi) => displayedDois.has(doi)) 21 | if (hasDisplayedPublications) { 22 | nodes.push({ 23 | id: author.id, 24 | author, 25 | type: 'author' 26 | }) 27 | } 28 | }) 29 | 30 | return nodes 31 | } 32 | 33 | /** 34 | * Create author links connecting authors to publications 35 | */ 36 | export function createAuthorLinks(filteredAuthors, publications, doiToIndex) { 37 | const links = [] 38 | const displayedDois = new Set(publications.map((pub) => pub.doi)) 39 | 40 | filteredAuthors.forEach((author) => { 41 | const hasDisplayedPublications = author.publicationDois.some((doi) => displayedDois.has(doi)) 42 | if (hasDisplayedPublications) { 43 | author.publicationDois.forEach((doi) => { 44 | if (doi in doiToIndex) { 45 | links.push({ 46 | source: author.id, 47 | target: doi, 48 | type: 'author' 49 | }) 50 | } 51 | }) 52 | } 53 | }) 54 | 55 | return links 56 | } 57 | 58 | /** 59 | * Initialize author node DOM elements 60 | */ 61 | // @indirection-reviewed: architectural-consistency - part of initialize*Nodes pattern for different node types 62 | export function initializeAuthorNodes(nodeSelection) { 63 | const authorNodes = nodeSelection.filter((d) => d.type === 'author') 64 | 65 | // Add circle element 66 | authorNodes.append('circle').attr('pointer-events', 'all').attr('r', 12).attr('fill', 'black') 67 | 68 | // Add text element for initials 69 | authorNodes.append('text').attr('pointer-events', 'none') 70 | 71 | return authorNodes 72 | } 73 | 74 | /** 75 | * Update author node visual properties 76 | */ 77 | export function updateAuthorNodes(nodeSelection, activePublication, existingTooltips) { 78 | const authorNodes = nodeSelection.filter((d) => d.type === 'author') 79 | 80 | // Update CSS classes based on state 81 | authorNodes 82 | .classed('linkedToActive', (d) => d.author.publicationDois.includes(activePublication?.doi)) 83 | .classed( 84 | 'non-active', 85 | (d) => activePublication && !d.author.publicationDois.includes(activePublication?.doi) 86 | ) 87 | 88 | // Clean up existing tooltips 89 | if (existingTooltips) { 90 | existingTooltips.forEach((tooltip) => tooltip.destroy()) 91 | } 92 | 93 | // Set up tooltip content 94 | authorNodes.attr('data-tippy-content', (d) => { 95 | const yearDisplay = 96 | d.author.yearMin === d.author.yearMax 97 | ? `in ${d.author.yearMin}` 98 | : `between ${d.author.yearMin} and ${d.author.yearMax}` 99 | 100 | return `${d.author.name} is linked 101 | to ${d.author.count} selected publication${d.author.count > 1 ? 's' : ''}, 102 | published ${yearDisplay}, 103 | with an aggregated, weighted score of ${d.author.score}. 104 | ` 105 | }) 106 | 107 | // Create new tooltips 108 | const newTooltips = tippy(authorNodes.nodes(), { 109 | maxWidth: 'min(400px,70vw)', 110 | allowHTML: true 111 | }) 112 | 113 | // Update text content and styling 114 | authorNodes 115 | .select('text') 116 | .text((d) => d.author.initials) 117 | .classed('long', (d) => d.author.initials.length > 2) 118 | .classed('very-long', (d) => d.author.initials.length > 3) 119 | 120 | return { nodes: authorNodes, tooltips: newTooltips } 121 | } 122 | 123 | /** 124 | * Highlight publications authored by the specified author 125 | */ 126 | export function highlightAuthorPublications(authorNode, publications) { 127 | publications.forEach((publication) => { 128 | if (authorNode.author.publicationDois.includes(publication.doi)) { 129 | publication.isAuthorHovered = true 130 | } 131 | }) 132 | } 133 | 134 | /** 135 | * Clear author highlighting from all publications 136 | */ 137 | // @indirection-reviewed: architectural-consistency - matches clearKeywordHighlight pattern 138 | export function clearAuthorHighlight(publications) { 139 | publications.forEach((publication) => { 140 | publication.isAuthorHovered = false 141 | }) 142 | } 143 | -------------------------------------------------------------------------------- /tests/unit/bugs/quickaccessbar-null-component.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { createPinia, setActivePinia } from 'pinia' 3 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 4 | 5 | import QuickAccessBar from '@/components/QuickAccessBar.vue' 6 | import { useQueueStore } from '@/stores/queue.js' 7 | 8 | // Mock external dependencies 9 | vi.mock('@/lib/Util.js', () => ({ 10 | scrollToTargetAdjusted: vi.fn() 11 | })) 12 | 13 | const mockUpdateQueued = vi.fn() 14 | vi.mock('@/composables/useAppState.js', () => ({ 15 | useAppState: () => ({ 16 | updateQueued: mockUpdateQueued 17 | }) 18 | })) 19 | 20 | describe('QuickAccessBar - Null Component Bug', () => { 21 | let pinia 22 | let queueStore 23 | 24 | beforeEach(() => { 25 | pinia = createPinia() 26 | setActivePinia(pinia) 27 | queueStore = useQueueStore() 28 | queueStore.selectedQueue = [] 29 | queueStore.excludedQueue = [] 30 | 31 | vi.clearAllMocks() 32 | 33 | // Mock document properties 34 | Object.defineProperty(document, 'documentElement', { 35 | value: { 36 | clientHeight: 1000 37 | }, 38 | writable: true, 39 | configurable: true 40 | }) 41 | }) 42 | 43 | afterEach(() => { 44 | vi.restoreAllMocks() 45 | }) 46 | 47 | it('should handle missing DOM elements when updateActiveButton is triggered via scroll', () => { 48 | // Mock getElementById to return null for missing elements (as would happen in real DOM) 49 | const originalGetElementById = document.getElementById 50 | document.getElementById = vi.fn((id) => { 51 | // Simulate scenario where components aren't in viewport so loop continues to 'network' 52 | // which hasn't rendered yet 53 | if (id === 'network') { 54 | return null 55 | } 56 | // Return elements that are NOT active (outside viewport) so loop continues 57 | return { 58 | getBoundingClientRect: vi.fn(() => ({ 59 | top: 2000, // Far below viewport 60 | bottom: 2100 61 | })) 62 | } 63 | }) 64 | 65 | let scrollHandler 66 | const originalAddEventListener = document.addEventListener 67 | document.addEventListener = vi.fn((event, handler) => { 68 | if (event === 'scroll') { 69 | scrollHandler = handler 70 | } 71 | return originalAddEventListener.call(document, event, handler) 72 | }) 73 | 74 | const wrapper = mount(QuickAccessBar, { 75 | global: { 76 | plugins: [pinia], 77 | stubs: { 78 | 'v-btn': { template: '' }, 79 | 'v-btn-toggle': { template: '
    ' }, 80 | 'v-icon': { template: '' } 81 | } 82 | } 83 | }) 84 | 85 | // After fix: should NOT throw an error when updateActiveButton handles null gracefully 86 | expect(() => { 87 | scrollHandler() 88 | }).not.toThrow() 89 | 90 | // Verify that all components remain inactive when missing element is encountered 91 | expect(wrapper.vm.isComponentActive.selected).toBe(false) 92 | expect(wrapper.vm.isComponentActive.suggested).toBe(false) 93 | expect(wrapper.vm.isComponentActive.network).toBe(false) 94 | 95 | wrapper.unmount() 96 | document.getElementById = originalGetElementById 97 | }) 98 | 99 | it('should handle case where all DOM elements are missing on scroll', () => { 100 | // Mock getElementById to always return null 101 | const originalGetElementById = document.getElementById 102 | document.getElementById = vi.fn(() => null) 103 | 104 | let scrollHandler 105 | const originalAddEventListener = document.addEventListener 106 | document.addEventListener = vi.fn((event, handler) => { 107 | if (event === 'scroll') { 108 | scrollHandler = handler 109 | } 110 | return originalAddEventListener.call(document, event, handler) 111 | }) 112 | 113 | const wrapper = mount(QuickAccessBar, { 114 | global: { 115 | plugins: [pinia], 116 | stubs: { 117 | 'v-btn': { template: '' }, 118 | 'v-btn-toggle': { template: '
    ' }, 119 | 'v-icon': { template: '' } 120 | } 121 | } 122 | }) 123 | 124 | // After fix: should NOT throw when all components are null 125 | expect(() => { 126 | scrollHandler() 127 | }).not.toThrow() 128 | 129 | // All should remain inactive when all elements are missing 130 | expect(wrapper.vm.isComponentActive.selected).toBe(false) 131 | expect(wrapper.vm.isComponentActive.suggested).toBe(false) 132 | expect(wrapper.vm.isComponentActive.network).toBe(false) 133 | 134 | wrapper.unmount() 135 | document.getElementById = originalGetElementById 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /.claude/commands/cleanup-comments.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Clean up the codebase by identifying and removing trivial, unnecessary, or outdated comments while preserving meaningful documentation. 3 | --- 4 | 5 | Clean up the codebase by analyzing comments for: 6 | 7 | 1. **Noise comments** - TODO items that are completed, debugging comments, or temporary notes 8 | 2. **Outdated comments** - Comments that no longer match the current implementation 9 | 3. **Redundant comments** - Comments that duplicate information available elsewhere 10 | 4. **Truly trivial comments** - Only the most obvious restatements of what code does 11 | 12 | ## Analysis Categories 13 | 14 | ### **Truly Trivial Comments to Remove** (Be Very Conservative) 15 | 16 | - Extremely obvious restatements: `// Return true` above `return true` 17 | - Direct variable assignments: `// Set x to 5` above `x = 5` 18 | - Only remove if the comment adds absolutely no value and the code is completely self-evident 19 | 20 | ### **Outdated Comments to Update or Remove** 21 | 22 | - Comments describing old implementations that have been refactored 23 | - Parameter descriptions that don't match current function signatures 24 | - Workflow descriptions that reference removed features 25 | - Architecture comments that describe obsolete patterns 26 | 27 | ### **Redundant Comments to Remove** 28 | 29 | - Comments duplicating JSDoc or similar documentation 30 | - Repeated explanations across similar functions 31 | - Comments that duplicate information in nearby code 32 | 33 | ### **Noise Comments to Clean Up** 34 | 35 | - Completed TODO items: `// TODO: Add validation` when validation exists 36 | - Debugging artifacts: `// console.log for testing`, commented-out `console.log` 37 | - Temporary development notes: `// FIXME: temporary solution` 38 | - Dead code comments: `// Old implementation below` with no code 39 | 40 | ## Preservation Guidelines 41 | 42 | ### **Comments to Keep** (Default: Preserve Most Comments) 43 | 44 | - **Business logic explanations**: Why specific algorithms or approaches were chosen 45 | - **Non-obvious calculations**: Mathematical formulas, complex transformations 46 | - **Integration requirements**: API contracts, external system dependencies 47 | - **Performance optimizations**: Explanations of performance-critical code choices 48 | - **Security considerations**: Authentication, validation, or sanitization rationale 49 | - **Bug workarounds**: Explanations of unusual code that addresses specific issues 50 | - **Complex regular expressions**: Pattern explanations and example matches 51 | - **Configuration explanations**: Why certain settings or flags are used 52 | - **Structural headers**: Comments that organize code sections and provide navigation 53 | - **Step-by-step sequences**: Comments that break down multi-step processes or algorithms 54 | - **Non-straightforward mechanisms**: Comments explaining code that isn't immediately clear 55 | - **Code organization**: Comments that improve readability by grouping related functionality 56 | - **Workflow organization**: Comments that separate logical phases like "// Clear all active states first" 57 | - **Section separators**: Comments that group related operations like "// Set active state for selected publications" 58 | - **Implementation guidance**: Comments that explain multi-step string processing operations 59 | - **Context provision**: Comments that explain what a block of code is doing at a high level 60 | 61 | ### **Comments to Improve Rather Than Remove** 62 | 63 | - Vague comments that could be more specific 64 | - Comments that could include examples 65 | - Outdated comments that still serve a purpose but need updating 66 | 67 | ## Process 68 | 69 | 1. **Scan codebase** systematically for comment patterns 70 | 2. **Categorize findings** into the cleanup categories above 71 | 3. **Prioritize changes** by impact and risk (start with safest removals) 72 | 4. **Apply changes** in focused commits by category or file 73 | 5. **Verify functionality** remains intact after cleanup 74 | 6. **Document summary** of cleanup performed 75 | 76 | ## Best Practices 77 | 78 | - **Default to preservation**: Only remove comments that are clearly problematic or add no value 79 | - **Preserve structural organization**: Keep comments that organize code sections and workflows 80 | - **Preserve intent over implementation**: Keep comments explaining "why", be very careful removing "what" 81 | - **Update rather than remove** when comments serve a purpose but are outdated 82 | - **Focus on truly problematic comments**: Only target completed TODOs, debugging artifacts, and dead code comments 83 | - **Be extremely conservative**: If there's any doubt about a comment's value, keep it 84 | - **Preserve workflow clarity**: Keep comments that break down complex operations into logical steps 85 | - **Maintain code organization**: Keep comments that group related functionality 86 | - **When in doubt, keep it**: Strongly err on the side of preservation - better to have too many comments than too few 87 | 88 | This systematic approach improves code maintainability by removing comment debt while preserving valuable documentation that aids understanding and maintenance. 89 | -------------------------------------------------------------------------------- /tests/unit/components/NetworkControls.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { setActivePinia, createPinia } from 'pinia' 3 | import { describe, it, expect, beforeEach, vi } from 'vitest' 4 | 5 | import NetworkControls from '@/components/NetworkControls.vue' 6 | 7 | // NetworkControls no longer uses useAppState - visibility is handled by parent container 8 | 9 | // Mock session store 10 | vi.mock('@/stores/session.js', () => ({ 11 | useSessionStore: () => ({ 12 | filter: { 13 | hasActiveFilters: vi.fn(() => false) 14 | } 15 | }) 16 | })) 17 | 18 | // Mock tippy for tooltips 19 | vi.mock('tippy.js', () => ({ default: vi.fn(() => ({})) })) 20 | 21 | describe('NetworkControls', () => { 22 | let pinia 23 | 24 | beforeEach(() => { 25 | pinia = createPinia() 26 | setActivePinia(pinia) 27 | }) 28 | 29 | const getComponentStubs = () => ({ 30 | 'v-btn': { template: '' }, 31 | 'v-btn-toggle': { template: '
    ' }, 32 | 'v-menu': { template: '
    ' }, 33 | 'v-list': { template: '
    ' }, 34 | 'v-list-item': { template: '
    ' }, 35 | 'v-list-item-title': { template: '
    ' }, 36 | 'v-checkbox': { template: '' }, 37 | 'v-slider': { template: '' }, 38 | CompactButton: { template: '' } 39 | }) 40 | 41 | // Note: Visibility tests removed - NetworkControls no longer handles isEmpty internally. 42 | // Visibility is now controlled by parent container (NetworkVisComponent) via v-show="!isEmpty" 43 | 44 | describe('Component structure', () => { 45 | it('contains expected zoom controls', () => { 46 | 47 | const wrapper = mount(NetworkControls, { 48 | global: { 49 | plugins: [pinia], 50 | stubs: getComponentStubs() 51 | } 52 | }) 53 | 54 | // Should contain zoom in, zoom out, and reset buttons (3 total) 55 | const compactButtons = wrapper.findAll('.compact-button') 56 | expect(compactButtons.length).toBeGreaterThanOrEqual(3) // at least zoom in, zoom out, reset 57 | }) 58 | 59 | it('contains node type toggle buttons', () => { 60 | 61 | const wrapper = mount(NetworkControls, { 62 | global: { 63 | plugins: [pinia], 64 | stubs: getComponentStubs() 65 | } 66 | }) 67 | 68 | const btnToggle = wrapper.find('.v-btn-toggle') 69 | expect(btnToggle.exists()).toBe(true) 70 | 71 | const toggleButtons = btnToggle.findAll('button') 72 | expect(toggleButtons).toHaveLength(4) // selected, suggested, keyword, author 73 | }) 74 | 75 | it('contains settings menu', () => { 76 | 77 | const wrapper = mount(NetworkControls, { 78 | global: { 79 | plugins: [pinia], 80 | stubs: getComponentStubs() 81 | } 82 | }) 83 | 84 | const menu = wrapper.find('.v-menu') 85 | expect(menu.exists()).toBe(true) 86 | }) 87 | }) 88 | 89 | describe('Event emissions', () => { 90 | beforeEach(() => { 91 | }) 92 | 93 | it('emits zoom event when zoom buttons are clicked', async () => { 94 | const wrapper = mount(NetworkControls, { 95 | global: { 96 | plugins: [pinia], 97 | stubs: getComponentStubs() 98 | } 99 | }) 100 | 101 | const compactButtons = wrapper.findAll('.compact-button') 102 | const zoomInButton = compactButtons[0] 103 | const zoomOutButton = compactButtons[1] 104 | 105 | await zoomInButton.trigger('click') 106 | expect(wrapper.emitted().zoom).toBeTruthy() 107 | expect(wrapper.emitted().zoom[0]).toEqual([1.2]) 108 | 109 | await zoomOutButton.trigger('click') 110 | expect(wrapper.emitted().zoom[1]).toEqual([0.8]) 111 | }) 112 | 113 | it('emits reset event when reset button is clicked', async () => { 114 | const wrapper = mount(NetworkControls, { 115 | global: { 116 | plugins: [pinia], 117 | stubs: getComponentStubs() 118 | } 119 | }) 120 | 121 | const compactButtons = wrapper.findAll('.compact-button') 122 | const resetButton = compactButtons[2] // third button is reset 123 | await resetButton.trigger('click') 124 | 125 | expect(wrapper.emitted().reset).toBeTruthy() 126 | }) 127 | 128 | it('emits plot event when node visibility is toggled', async () => { 129 | const wrapper = mount(NetworkControls, { 130 | global: { 131 | plugins: [pinia], 132 | stubs: getComponentStubs() 133 | } 134 | }) 135 | 136 | const btnToggle = wrapper.find('.v-btn-toggle') 137 | await btnToggle.trigger('click') 138 | 139 | expect(wrapper.emitted().plot).toBeTruthy() 140 | expect(wrapper.emitted().plot[0]).toEqual([true]) 141 | }) 142 | }) 143 | }) -------------------------------------------------------------------------------- /tests/unit/components/PublicationDescription.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { describe, it, expect, vi, beforeEach } from 'vitest' 3 | 4 | import PublicationDescription from '@/components/PublicationDescription.vue' 5 | 6 | // Mock stores 7 | const mockSessionStore = { 8 | filter: { 9 | addDoi: vi.fn(), 10 | toggleDoi: vi.fn(), 11 | toggleTag: vi.fn(), 12 | dois: [], 13 | tags: [] 14 | }, 15 | exportSingleBibtex: vi.fn(), 16 | getSelectedPublicationByDoi: vi.fn(() => ({ 17 | title: 'Test Publication', 18 | authorShort: 'Smith et al.', 19 | year: 2023 20 | })) 21 | } 22 | 23 | const mockInterfaceStore = { 24 | isFilterMenuOpen: false, 25 | openFilterMenu: vi.fn(), 26 | showAbstract: vi.fn() 27 | } 28 | 29 | // Mock the store imports 30 | vi.mock('@/stores/session.js', () => ({ 31 | useSessionStore: () => mockSessionStore 32 | })) 33 | 34 | vi.mock('@/stores/interface.js', () => ({ 35 | useInterfaceStore: () => mockInterfaceStore 36 | })) 37 | 38 | describe('PublicationDescription', () => { 39 | let mockPublication 40 | 41 | beforeEach(() => { 42 | vi.clearAllMocks() 43 | 44 | mockPublication = { 45 | doi: '10.1234/test-publication', 46 | title: 'Test Publication Title', 47 | titleHighlighted: null, 48 | author: 'John Doe, Jane Smith', 49 | authorShort: 'Doe et al.', 50 | authorOrcidHtml: 'John Doe 0000-0000-0000-0001, Jane Smith', 51 | year: 2023, 52 | container: 'Test Journal', 53 | doiUrl: 'https://doi.org/10.1234/test-publication', 54 | referenceDois: ['10.1234/ref1', '10.1234/ref2'], 55 | citationDois: ['10.1234/cite1'], 56 | citationsPerYear: 5.2, 57 | tooManyCitations: false, 58 | isActive: false, 59 | isSelected: true, 60 | wasFetched: true, 61 | abstract: 'This is a test abstract.', 62 | gsUrl: 'https://scholar.google.com/scholar?q=test', 63 | isHighlyCited: false, 64 | isSurvey: false, 65 | isNew: false, 66 | isUnnoted: false 67 | } 68 | }) 69 | 70 | const defaultStubs = { 71 | 'v-icon': true, 72 | InlineIcon: true, 73 | CompactButton: { template: '' }, 74 | PublicationTag: { template: '' } 75 | } 76 | 77 | it('renders publication with conditional details display', () => { 78 | let wrapper = mount(PublicationDescription, { 79 | props: { publication: mockPublication }, 80 | global: { stubs: defaultStubs } 81 | }) 82 | expect(wrapper.text()).toContain('Test Publication Title') 83 | 84 | wrapper = mount(PublicationDescription, { 85 | props: { publication: { ...mockPublication, isActive: true }, alwaysShowDetails: false }, 86 | global: { stubs: defaultStubs } 87 | }) 88 | expect(wrapper.text()).toContain('DOI:') 89 | expect(wrapper.text()).toContain('10.1234/test-publication') 90 | expect(wrapper.text()).toContain('Citing: 2') 91 | 92 | wrapper = mount(PublicationDescription, { 93 | props: { publication: mockPublication, alwaysShowDetails: true }, 94 | global: { stubs: defaultStubs } 95 | }) 96 | expect(wrapper.text()).toContain('DOI:') 97 | 98 | wrapper = mount(PublicationDescription, { 99 | props: { publication: { ...mockPublication, isActive: false }, alwaysShowDetails: false }, 100 | global: { stubs: defaultStubs } 101 | }) 102 | expect(wrapper.text()).not.toContain('DOI:') 103 | }) 104 | 105 | it('highlights search terms when provided', () => { 106 | const wrapper = mount(PublicationDescription, { 107 | props: { publication: mockPublication, highlighted: 'Test Publication' }, 108 | global: { stubs: defaultStubs } 109 | }) 110 | expect(wrapper.html()).toContain('has-background-grey-light') 111 | }) 112 | 113 | it('calls showAbstract when abstract button is clicked', async () => { 114 | const wrapper = mount(PublicationDescription, { 115 | props: { publication: { ...mockPublication, isActive: true } }, 116 | global: { 117 | stubs: { 118 | ...defaultStubs, 119 | CompactButton: { 120 | template: '', 121 | emits: ['click'] 122 | } 123 | } 124 | } 125 | }) 126 | 127 | const abstractButton = wrapper.findAllComponents({ name: 'CompactButton' }) 128 | .find(button => button.attributes('icon') === 'mdi-text') 129 | 130 | if (abstractButton) { 131 | await abstractButton.trigger('click') 132 | expect(mockInterfaceStore.showAbstract).toHaveBeenCalledWith({ ...mockPublication, isActive: true }) 133 | } 134 | }) 135 | 136 | it('renders publication tags', () => { 137 | const wrapper = mount(PublicationDescription, { 138 | props: { 139 | publication: { ...mockPublication, isHighlyCited: true, isSurvey: true, isNew: true } 140 | }, 141 | global: { stubs: defaultStubs } 142 | }) 143 | expect(wrapper.findAll('.tag').length).toBe(3) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /src/utils/network/forces.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Network Force Simulation Configuration 3 | * 4 | * This module handles the D3.js force simulation setup for the citation network. 5 | * It provides functions to configure link, charge, and positioning forces based on 6 | * network mode (timeline vs clusters) and publication data. 7 | */ 8 | 9 | import * as d3 from 'd3' 10 | 11 | import { CURRENT_YEAR } from '@/constants/config.js' 12 | 13 | export const SIMULATION_ALPHA = 0.7 14 | 15 | /** 16 | * Calculate link distance based on link type and network mode 17 | */ 18 | export function getLinkDistance(link, isNetworkClusters, selectedPublicationsCount) { 19 | switch (link.type) { 20 | case 'citation': 21 | return isNetworkClusters && link.internal ? 1500 / selectedPublicationsCount : 10 22 | case 'keyword': 23 | return 0 24 | case 'author': 25 | return 0 26 | default: 27 | return 10 28 | } 29 | } 30 | 31 | /** 32 | * Calculate link strength based on link type and network mode 33 | */ 34 | export function getLinkStrength(link, isNetworkClusters) { 35 | let internalFactor 36 | if (link.type === 'citation') { 37 | internalFactor = link.internal ? 1 : 1.5 38 | } else if (link.type === 'keyword') { 39 | internalFactor = 0.5 40 | } else { 41 | internalFactor = 2.5 // author 42 | } 43 | 44 | const clustersFactor = isNetworkClusters ? 1 : 0.5 45 | return 0.05 * clustersFactor * internalFactor 46 | } 47 | 48 | /** 49 | * Calculate charge (repulsion) strength based on number of selected publications 50 | */ 51 | // @indirection-reviewed: architectural-consistency - part of get*ForceStrength pattern 52 | export function getChargeStrength(selectedPublicationsCount) { 53 | return Math.min(-200, -100 * Math.sqrt(selectedPublicationsCount)) 54 | } 55 | 56 | /** 57 | * Calculate X coordinate based on year and display dimensions 58 | */ 59 | export function calculateYearX(year, svgWidth, svgHeight, isMobile) { 60 | const width = Math.max(svgWidth, 2 * svgHeight) 61 | return (year - CURRENT_YEAR) * width * 0.03 + width * (isMobile ? 0.05 : 0.3) 62 | } 63 | 64 | /** 65 | * Calculate X position for a node based on type and network mode 66 | */ 67 | export function getNodeXPosition(node, isNetworkClusters, yearXCalculator) { 68 | if (isNetworkClusters) { 69 | return node.x || 0 70 | } 71 | 72 | switch (node.type) { 73 | case 'publication': 74 | return yearXCalculator(node.publication.year) 75 | case 'keyword': 76 | return yearXCalculator(CURRENT_YEAR + 2) 77 | case 'author': 78 | return yearXCalculator((node.author.yearMax + node.author.yearMin) / 2) 79 | default: 80 | return node.x || 0 81 | } 82 | } 83 | 84 | /** 85 | * Calculate X force strength based on node type and network mode 86 | */ 87 | // @indirection-reviewed: architectural-consistency - part of get*ForceStrength pattern 88 | export function getXForceStrength(node, isNetworkClusters) { 89 | if (isNetworkClusters) { 90 | return 0.05 91 | } 92 | 93 | return node.type === 'author' ? 0.2 : 10 94 | } 95 | 96 | /** 97 | * Calculate Y force strength based on network mode 98 | */ 99 | // @indirection-reviewed: architectural-consistency - part of get*ForceStrength pattern 100 | export function getYForceStrength(isNetworkClusters) { 101 | return isNetworkClusters ? 0.1 : 0.25 102 | } 103 | 104 | /** 105 | * Initialize and configure all forces for the simulation 106 | */ 107 | export function initializeForces(simulation, config) { 108 | const { isNetworkClusters, selectedPublicationsCount, yearXCalculator, tickHandler } = config 109 | 110 | // Configure link force 111 | simulation.force( 112 | 'link', 113 | d3 114 | .forceLink() 115 | .id((d) => d.id) 116 | .distance((d) => getLinkDistance(d, isNetworkClusters, selectedPublicationsCount)) 117 | .strength((d) => getLinkStrength(d, isNetworkClusters)) 118 | ) 119 | 120 | // Configure charge (repulsion) force 121 | simulation.force( 122 | 'charge', 123 | d3.forceManyBody().strength(getChargeStrength(selectedPublicationsCount)).theta(0.7) // Optimized Barnes-Hut parameter for better performance 124 | ) 125 | 126 | // Configure X positioning force 127 | simulation.force( 128 | 'x', 129 | d3 130 | .forceX() 131 | .x((d) => getNodeXPosition(d, isNetworkClusters, yearXCalculator)) 132 | .strength((d) => getXForceStrength(d, isNetworkClusters)) 133 | ) 134 | 135 | // Configure Y positioning force 136 | simulation.force('y', d3.forceY().y(0).strength(getYForceStrength(isNetworkClusters))) 137 | 138 | // Set up tick handler 139 | if (tickHandler) { 140 | simulation.on('tick', tickHandler) 141 | } 142 | 143 | return simulation 144 | } 145 | 146 | /** 147 | * Create and initialize a new D3 force simulation 148 | */ 149 | // @indirection-reviewed: architectural-consistency - maintains clean API separation with initializeForces 150 | export function createForceSimulation(config) { 151 | const simulation = d3.forceSimulation() 152 | simulation.alphaDecay(0.015).alphaMin(0.015) 153 | return initializeForces(simulation, config) 154 | } 155 | -------------------------------------------------------------------------------- /tests/unit/services/SuggestionService.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest' 2 | 3 | import { SuggestionService } from '@/services/SuggestionService.js' 4 | 5 | // Mock dependencies - must be before imports 6 | vi.mock('@/core/Publication.js', () => ({ 7 | default: vi.fn() 8 | })) 9 | vi.mock('@/lib/Util.js', () => ({ 10 | shuffle: vi.fn((arr) => arr) // Return array as-is for predictable tests 11 | })) 12 | vi.mock('@/lib/Cache.js', () => ({ 13 | cachedFetch: vi.fn() 14 | })) 15 | 16 | const Publication = vi.mocked(await import('@/core/Publication.js')).default 17 | 18 | describe('SuggestionService', () => { 19 | let mockSelectedPublications 20 | let mockOptions 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks() 24 | 25 | // Mock Publication constructor 26 | Publication.mockImplementation((doi) => ({ 27 | doi, 28 | citationCount: 0, 29 | referenceCount: 0, 30 | citationDois: [], 31 | referenceDois: [], 32 | fetchData: vi.fn().mockResolvedValue(), 33 | isRead: false 34 | })) 35 | 36 | mockSelectedPublications = [ 37 | { 38 | doi: '10.1234/selected1', 39 | citationCount: 0, 40 | referenceCount: 0, 41 | citationDois: ['10.1234/citation1', '10.1234/citation2'], 42 | referenceDois: ['10.1234/reference1'] 43 | }, 44 | { 45 | doi: '10.1234/selected2', 46 | citationCount: 0, 47 | referenceCount: 0, 48 | citationDois: ['10.1234/citation2'], 49 | referenceDois: ['10.1234/reference2'] 50 | } 51 | ] 52 | 53 | mockOptions = { 54 | selectedPublications: mockSelectedPublications, 55 | isExcluded: vi.fn(() => false), 56 | isSelected: vi.fn(() => false), 57 | getSelectedPublicationByDoi: vi.fn(), 58 | maxSuggestions: 10, 59 | readPublicationsDois: new Set(), 60 | updateLoadingMessage: vi.fn() 61 | } 62 | }) 63 | 64 | describe('computeSuggestions', () => { 65 | it('should compute suggestions from citation network', async () => { 66 | const result = await SuggestionService.computeSuggestions(mockOptions) 67 | 68 | expect(result).toHaveProperty('publications') 69 | expect(result).toHaveProperty('totalSuggestions') 70 | expect(Array.isArray(result.publications)).toBe(true) 71 | expect(typeof result.totalSuggestions).toBe('number') 72 | }) 73 | 74 | it('should reset citation/reference counts for selected publications', async () => { 75 | await SuggestionService.computeSuggestions(mockOptions) 76 | 77 | mockSelectedPublications.forEach((pub) => { 78 | expect(pub.citationCount).toBe(0) 79 | expect(pub.referenceCount).toBe(0) 80 | }) 81 | }) 82 | 83 | it('should call updateLoadingMessage during processing', async () => { 84 | await SuggestionService.computeSuggestions(mockOptions) 85 | 86 | expect(mockOptions.updateLoadingMessage).toHaveBeenCalledWith( 87 | expect.stringContaining('suggestions loaded') 88 | ) 89 | }) 90 | 91 | it('should exclude DOIs that are marked as excluded', async () => { 92 | mockOptions.isExcluded = vi.fn((doi) => doi === '10.1234/citation1') 93 | 94 | const result = await SuggestionService.computeSuggestions(mockOptions) 95 | 96 | const suggestionDois = result.publications.map((p) => p.doi) 97 | expect(suggestionDois).not.toContain('10.1234/citation1') 98 | }) 99 | 100 | it('should not suggest DOIs that are already selected', async () => { 101 | mockOptions.isSelected = vi.fn((_doi) => _doi === '10.1234/citation2') 102 | mockOptions.getSelectedPublicationByDoi = vi.fn((_doi) => ({ 103 | citationCount: 0, 104 | referenceCount: 0 105 | })) 106 | 107 | const result = await SuggestionService.computeSuggestions(mockOptions) 108 | 109 | const suggestionDois = result.publications.map((p) => p.doi) 110 | expect(suggestionDois).not.toContain('10.1234/citation2') 111 | }) 112 | 113 | it('should mark publications as read if they are in readPublicationsDois', async () => { 114 | const readDois = new Set(['10.1234/citation1']) 115 | mockOptions.readPublicationsDois = readDois 116 | 117 | const result = await SuggestionService.computeSuggestions(mockOptions) 118 | 119 | const readPub = result.publications.find((p) => p.doi === '10.1234/citation1') 120 | if (readPub) { 121 | expect(readPub.isRead).toBe(true) 122 | } 123 | }) 124 | 125 | it('should limit suggestions to maxSuggestions', async () => { 126 | mockOptions.maxSuggestions = 2 127 | 128 | const result = await SuggestionService.computeSuggestions(mockOptions) 129 | 130 | expect(result.publications.length).toBeLessThanOrEqual(2) 131 | }) 132 | 133 | it('should fetch data for all suggested publications', async () => { 134 | const result = await SuggestionService.computeSuggestions(mockOptions) 135 | 136 | // Verify fetchData was called for each publication 137 | result.publications.forEach((pub) => { 138 | expect(pub.fetchData).toHaveBeenCalled() 139 | }) 140 | }) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/core/Filter.js: -------------------------------------------------------------------------------- 1 | const VALIDATION = { 2 | MIN_YEAR: 1000, 3 | MAX_YEAR: 10000 4 | } 5 | 6 | export default class Filter { 7 | constructor() { 8 | this.string = '' 9 | this.yearStart = undefined 10 | this.yearEnd = undefined 11 | this.tags = [] 12 | this.doi = '' 13 | this.dois = [] 14 | this.authors = [] 15 | this.isActive = true 16 | this.applyToSelected = true 17 | this.applyToSuggested = true 18 | } 19 | 20 | matchesString(publication) { 21 | if (!this.string) return true 22 | return publication.getMetaString().toLowerCase().indexOf(this.string.toLowerCase()) >= 0 23 | } 24 | 25 | isSpecificYearActive(yearNumeric) { 26 | return ( 27 | yearNumeric != null && 28 | !isNaN(yearNumeric) && 29 | yearNumeric >= VALIDATION.MIN_YEAR && 30 | yearNumeric < VALIDATION.MAX_YEAR 31 | ) 32 | } 33 | 34 | isYearActive() { 35 | return ( 36 | this.isSpecificYearActive(Number(this.yearStart)) || 37 | this.isSpecificYearActive(Number(this.yearEnd)) 38 | ) 39 | } 40 | 41 | matchesYearStart(publication) { 42 | const yearStartNumeric = Number(this.yearStart) 43 | if (!this.isSpecificYearActive(yearStartNumeric)) return true 44 | return yearStartNumeric <= Number(publication.year) 45 | } 46 | 47 | matchesYearEnd(publication) { 48 | const yearEndNumeric = Number(this.yearEnd) 49 | if (!this.isSpecificYearActive(yearEndNumeric)) return true 50 | return yearEndNumeric >= Number(publication.year) 51 | } 52 | 53 | matchesYear(publication) { 54 | if (!publication.year) return false 55 | return this.matchesYearStart(publication) && this.matchesYearEnd(publication) 56 | } 57 | 58 | matchesTag(publication) { 59 | if (!this.tags || this.tags.length === 0) return true 60 | return this.tags.some((tag) => Boolean(publication[tag])) 61 | } 62 | 63 | toggleTag(tagValue) { 64 | if (this.tags.includes(tagValue)) { 65 | this.removeTag(tagValue) 66 | } else { 67 | this.addTag(tagValue) 68 | } 69 | } 70 | 71 | addTag(tagValue) { 72 | if (!this.tags.includes(tagValue)) { 73 | this.tags.push(tagValue) 74 | } 75 | } 76 | 77 | removeTag(tagValue) { 78 | this.tags = this.tags.filter((t) => t !== tagValue) 79 | } 80 | 81 | toggleDoi(doi) { 82 | if (this.dois.includes(doi)) { 83 | this.removeDoi(doi) 84 | } else { 85 | this.addDoi(doi) 86 | } 87 | } 88 | 89 | addDoi(doi) { 90 | if (!this.dois.includes(doi)) { 91 | this.dois.push(doi) 92 | } 93 | } 94 | 95 | removeDoi(doi) { 96 | console.log(`Removing DOI: ${doi}`) 97 | this.dois = this.dois.filter((d) => d !== doi) 98 | console.log(`Remaining DOIs: ${this.dois}`) 99 | } 100 | 101 | toggleAuthor(authorId) { 102 | if (this.authors.includes(authorId)) { 103 | this.removeAuthor(authorId) 104 | } else { 105 | this.addAuthor(authorId) 106 | } 107 | } 108 | 109 | addAuthor(authorId) { 110 | if (!this.authors.includes(authorId)) { 111 | this.authors.push(authorId) 112 | } 113 | } 114 | 115 | removeAuthor(authorId) { 116 | this.authors = this.authors.filter((a) => a !== authorId) 117 | } 118 | 119 | matchesDois(publication) { 120 | if (!this.dois.length) return true 121 | return this.dois.some( 122 | (doi) => 123 | publication.doi === doi || 124 | publication.citationDois.includes(doi) || 125 | publication.referenceDois.includes(doi) 126 | ) 127 | } 128 | 129 | matchesAuthors(publication) { 130 | if (!this.authors.length) return true 131 | if (!publication.author) return false 132 | 133 | // Split authors by semicolon to get individual author names 134 | const authorNames = publication.author.split(';').map((name) => name.trim()) 135 | 136 | // Check if any of the publication's authors match any of the filtered author IDs 137 | return this.authors.some((authorId) => { 138 | return authorNames.some((authorName) => { 139 | // Normalize author name to ID format for comparison 140 | const normalizedName = authorName 141 | .normalize('NFD') 142 | .replace(/[\u0300-\u036f]/g, '') 143 | .replace(/[øØ]/g, 'o') 144 | .replace(/[åÅ]/g, 'a') 145 | .replace(/[æÆ]/g, 'ae') 146 | .replace(/[ðÐ]/g, 'd') 147 | .replace(/[þÞ]/g, 'th') 148 | .replace(/[ßẞ]/g, 'ss') 149 | .toLowerCase() 150 | return normalizedName === authorId 151 | }) 152 | }) 153 | } 154 | 155 | matches(publication) { 156 | if (!this.isActive) return true 157 | return ( 158 | this.matchesString(publication) && 159 | this.matchesTag(publication) && 160 | this.matchesYear(publication) && 161 | this.matchesDois(publication) && 162 | this.matchesAuthors(publication) 163 | ) 164 | } 165 | 166 | hasActiveFilters() { 167 | return ( 168 | this.isActive && 169 | Boolean(this.string || this.tags.length > 0 || this.isYearActive() || this.dois.length > 0 || this.authors.length > 0) && 170 | (this.applyToSelected || this.applyToSuggested) 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/utils/network/keywordNodes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Keyword Node Management 3 | * 4 | * This module handles the creation, initialization, and updating of keyword nodes 5 | * in the network visualization. Keyword nodes represent research keywords and are 6 | * displayed as text-based nodes that can be dragged and repositioned. 7 | */ 8 | 9 | import * as d3 from 'd3' 10 | import tippy from 'tippy.js' 11 | 12 | /** 13 | * Create keyword node data from unique boost keywords 14 | */ 15 | export function createKeywordNodes(uniqueBoostKeywords, publications) { 16 | const nodes = [] 17 | 18 | uniqueBoostKeywords.forEach((keyword) => { 19 | const frequency = publications.filter((publication) => 20 | publication.boostKeywords.includes(keyword) 21 | ).length 22 | 23 | nodes.push({ 24 | id: keyword, 25 | frequency, 26 | type: 'keyword' 27 | }) 28 | }) 29 | 30 | return nodes 31 | } 32 | 33 | /** 34 | * Create keyword links connecting keywords to publications 35 | */ 36 | export function createKeywordLinks(uniqueBoostKeywords, publicationsFiltered, doiToIndex) { 37 | const links = [] 38 | 39 | uniqueBoostKeywords.forEach((keyword) => { 40 | publicationsFiltered.forEach((publication) => { 41 | if (publication.doi in doiToIndex && publication.boostKeywords.includes(keyword)) { 42 | links.push({ 43 | source: keyword, 44 | target: publication.doi, 45 | type: 'keyword' 46 | }) 47 | } 48 | }) 49 | }) 50 | 51 | return links 52 | } 53 | 54 | /** 55 | * Initialize keyword node DOM elements 56 | */ 57 | // @indirection-reviewed: architectural-consistency - part of initialize*Nodes pattern for different node types 58 | export function initializeKeywordNodes(nodeSelection) { 59 | const keywordNodes = nodeSelection.filter((d) => d.type === 'keyword') 60 | 61 | // Add text element 62 | keywordNodes.append('text') 63 | 64 | return keywordNodes 65 | } 66 | 67 | /** 68 | * Update keyword node visual properties 69 | */ 70 | export function updateKeywordNodes(nodeSelection, activePublication, existingTooltips) { 71 | const keywordNodes = nodeSelection.filter((d) => d.type === 'keyword') 72 | 73 | // Update CSS classes based on state 74 | keywordNodes 75 | .classed( 76 | 'linkedToActive', 77 | (d) => activePublication && activePublication.boostKeywords.includes(d.id) 78 | ) 79 | .classed( 80 | 'non-active', 81 | (d) => activePublication && !activePublication.boostKeywords.includes(d.id) 82 | ) 83 | 84 | // Clean up existing tooltips 85 | if (existingTooltips) { 86 | existingTooltips.forEach((tooltip) => tooltip.destroy()) 87 | } 88 | 89 | // Set up tooltip content 90 | keywordNodes.attr('data-tippy-content', (d) => { 91 | const isLinked = activePublication && activePublication.boostKeywords.includes(d.id) 92 | return `Keyword ${d.id} is matched in ${d.frequency} publication${d.frequency > 1 ? 's' : ''}${ 93 | isLinked ? ', and also linked to the currently active publication' : '' 94 | }.

    Drag to reposition (sticky), click to detach.` 95 | }) 96 | 97 | // Create new tooltips 98 | const newTooltips = tippy(keywordNodes.nodes(), { 99 | maxWidth: 'min(400px,70vw)', 100 | allowHTML: true 101 | }) 102 | 103 | // Update text content and styling 104 | keywordNodes 105 | .select('text') 106 | .attr('font-size', (d) => { 107 | if (d.frequency >= 10) return '0.8em' 108 | return '0.7em' 109 | }) 110 | .text((d) => { 111 | if (d.id.includes('|')) { 112 | return `${d.id.split('|')[0] }|..` 113 | } 114 | return d.id 115 | }) 116 | 117 | return { nodes: keywordNodes, tooltips: newTooltips } 118 | } 119 | 120 | /** 121 | * Release fixed positioning from a keyword node 122 | */ 123 | // @indirection-reviewed: meaningful-abstraction - clear UI interaction semantics for node positioning 124 | export function releaseKeywordPosition(event, keywordNode, networkSimulation, SIMULATION_ALPHA) { 125 | delete keywordNode.fx 126 | delete keywordNode.fy 127 | d3.select(event.target.parentNode).classed('fixed', false) 128 | networkSimulation.restart(SIMULATION_ALPHA) 129 | } 130 | 131 | /** 132 | * Highlight publications that contain the specified keyword 133 | */ 134 | export function highlightKeywordPublications(keywordNode, publications) { 135 | publications.forEach((publication) => { 136 | if (publication.boostKeywords.includes(keywordNode.id)) { 137 | publication.isKeywordHovered = true 138 | } 139 | }) 140 | } 141 | 142 | /** 143 | * Clear keyword highlighting from all publications 144 | */ 145 | // @indirection-reviewed: architectural-consistency - matches clearAuthorHighlight pattern 146 | export function clearKeywordHighlight(publications) { 147 | publications.forEach((publication) => { 148 | publication.isKeywordHovered = false 149 | }) 150 | } 151 | 152 | /** 153 | * Create drag behavior for keyword nodes 154 | */ 155 | export function createKeywordNodeDrag(networkSimulation, SIMULATION_ALPHA) { 156 | function dragStart() { 157 | d3.select(this).classed('fixed', true) 158 | networkSimulation.setDragging(true) 159 | } 160 | 161 | function dragMove(event, d) { 162 | d.fx = event.x 163 | d.fy = event.y 164 | networkSimulation.restart(SIMULATION_ALPHA) 165 | } 166 | 167 | function dragEnd() { 168 | networkSimulation.setDragging(false) 169 | } 170 | 171 | return d3.drag().on('start', dragStart).on('drag', dragMove).on('end', dragEnd) 172 | } 173 | -------------------------------------------------------------------------------- /tests/unit/components/PublicationListComponent.test.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import { setActivePinia, createPinia } from 'pinia' 3 | import { describe, it, expect, beforeEach, vi } from 'vitest' 4 | 5 | import PublicationListComponent from '@/components/PublicationListComponent.vue' 6 | import { useInterfaceStore } from '@/stores/interface.js' 7 | import { useSessionStore } from '@/stores/session.js' 8 | 9 | // Mock stores and external dependencies 10 | vi.mock('@/stores/session.js') 11 | vi.mock('@/stores/interface.js') 12 | vi.mock('@/lib/Cache.js', () => ({ 13 | clearCache: vi.fn() 14 | })) 15 | vi.mock('@/lib/Util.js', () => ({ 16 | scrollToTargetAdjusted: vi.fn(), 17 | shuffle: vi.fn((arr) => arr), 18 | saveAsFile: vi.fn() 19 | })) 20 | 21 | // Mock LazyPublicationComponent with simplified approach 22 | vi.mock('@/components/LazyPublicationComponent.vue', () => ({ 23 | default: { 24 | name: 'LazyPublicationComponent', 25 | props: ['publication'], 26 | template: 27 | '
    {{ publication.doi }}
    ' 28 | } 29 | })) 30 | 31 | describe('PublicationListComponent - Section Headers', () => { 32 | let wrapper 33 | let mockSessionStore 34 | let mockInterfaceStore 35 | let mockPublications 36 | 37 | beforeEach(() => { 38 | setActivePinia(createPinia()) 39 | 40 | // Simplified mock publications 41 | mockPublications = [ 42 | { 43 | doi: '10.1234/pub1', 44 | score: 10, 45 | getMetaString: () => 'Machine Learning Paper' 46 | }, 47 | { 48 | doi: '10.1234/pub2', 49 | score: 5, 50 | getMetaString: () => 'Deep Learning Research' 51 | } 52 | ] 53 | 54 | // Simplified store mocking 55 | mockSessionStore = { 56 | filter: { 57 | matches: vi.fn(), 58 | hasActiveFilters: vi.fn(() => true), 59 | applyToSelected: true, 60 | applyToSuggested: true 61 | }, 62 | selectedPublicationsFilteredCount: 1, 63 | selectedPublicationsNonFilteredCount: 1, 64 | suggestedPublicationsFilteredCount: 1, 65 | suggestedPublicationsNonFilteredCount: 1 66 | } 67 | 68 | mockInterfaceStore = {} 69 | 70 | vi.mocked(useSessionStore).mockReturnValue(mockSessionStore) 71 | vi.mocked(useInterfaceStore).mockReturnValue(mockInterfaceStore) 72 | }) 73 | 74 | describe('section headers', () => { 75 | it.each([ 76 | ['selected', false], // [publicationType, expectInfoTheme] 77 | ['suggested', true] 78 | ])( 79 | 'should show filtered/unfiltered headers for %s publications', 80 | (publicationType, expectInfoTheme) => { 81 | // Mock filter to match first publication only 82 | mockSessionStore.filter.matches.mockImplementation((pub) => pub.doi === '10.1234/pub1') 83 | 84 | wrapper = mount(PublicationListComponent, { 85 | props: { 86 | publications: mockPublications, 87 | showSectionHeaders: true, 88 | publicationType 89 | } 90 | }) 91 | 92 | const headers = wrapper.findAll('.section-header-text') 93 | expect(headers).toHaveLength(2) 94 | expect(headers[0].text()).toBe('Filtered (1)') 95 | expect(headers[1].text()).toBe('Other publications (1)') 96 | 97 | // Check theme styling 98 | headers.forEach((header) => { 99 | if (expectInfoTheme) { 100 | expect(header.classes()).toContain('info-theme') 101 | } else { 102 | expect(header.classes()).not.toContain('info-theme') 103 | } 104 | }) 105 | } 106 | ) 107 | }) 108 | 109 | describe('no section headers when filters inactive', () => { 110 | it('should not show headers when no active filters', () => { 111 | mockSessionStore.filter.hasActiveFilters.mockReturnValue(false) 112 | 113 | wrapper = mount(PublicationListComponent, { 114 | props: { 115 | publications: mockPublications, 116 | showSectionHeaders: true, 117 | publicationType: 'selected' 118 | } 119 | }) 120 | 121 | const headers = wrapper.findAll('.section-header-text') 122 | expect(headers).toHaveLength(0) 123 | }) 124 | }) 125 | 126 | describe('checkbox-based filtering', () => { 127 | it.each([ 128 | ['selected', 'applyToSelected'], 129 | ['suggested', 'applyToSuggested'] 130 | ])( 131 | 'should control header visibility for %s publications via %s flag', 132 | (publicationType, filterFlag) => { 133 | mockSessionStore.filter.matches.mockImplementation((pub) => pub.doi === '10.1234/pub1') 134 | 135 | // Test with flag disabled - no headers 136 | mockSessionStore.filter[filterFlag] = false 137 | wrapper = mount(PublicationListComponent, { 138 | props: { 139 | publications: mockPublications, 140 | showSectionHeaders: true, 141 | publicationType 142 | } 143 | }) 144 | expect(wrapper.findAll('.section-header-text')).toHaveLength(0) 145 | 146 | // Test with flag enabled - headers shown 147 | mockSessionStore.filter[filterFlag] = true 148 | wrapper = mount(PublicationListComponent, { 149 | props: { 150 | publications: mockPublications, 151 | showSectionHeaders: true, 152 | publicationType 153 | } 154 | }) 155 | expect(wrapper.findAll('.section-header-text')).toHaveLength(2) 156 | } 157 | ) 158 | }) 159 | }) 160 | -------------------------------------------------------------------------------- /src/components/SessionMenuComponent.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 155 | 156 | 161 | -------------------------------------------------------------------------------- /src/components/HeaderPanel.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 81 | 82 | 163 | -------------------------------------------------------------------------------- /src/lib/Cache.js: -------------------------------------------------------------------------------- 1 | import { get, set, keys, del, clear } from 'idb-keyval' 2 | import LZString from 'lz-string' 3 | 4 | const CACHE_CONFIG = { 5 | EXPIRY_MS: 1000 * 60 * 60 * 24 * 100, // 100 days in milliseconds 6 | CLEANUP_BATCH_SIZE: 100 7 | } 8 | 9 | // In-memory cache layer for frequently accessed items 10 | const memoryCache = new Map() 11 | const MAX_MEMORY_CACHE_SIZE = 2000 // Keep 2000 most recent items in memory 12 | 13 | // Only access IndexedDB if available (not in test environment) 14 | if (typeof indexedDB !== 'undefined') { 15 | try { 16 | const keyCount = (await keys()).length 17 | console.log(`Locally cached #elements: ${keyCount}`) 18 | } catch (error) { 19 | console.warn('IndexedDB not available:', error.message) 20 | } 21 | } 22 | 23 | // Warm up memory cache by loading IndexedDB entries 24 | async function warmUpMemoryCache() { 25 | try { 26 | const allKeys = await keys() 27 | const warmUpCount = Math.min(500, allKeys.length) // Load up to 500 entries 28 | 29 | console.log(`🔥 Warming up memory cache with ${warmUpCount} entries...`) 30 | 31 | for (let i = 0; i < warmUpCount; i++) { 32 | try { 33 | const key = allKeys[i] 34 | const cacheObject = await get(key) 35 | 36 | if (cacheObject && cacheObject.data) { 37 | const data = JSON.parse(LZString.decompress(cacheObject.data)) 38 | addToMemoryCache(key, data) 39 | } 40 | } catch { 41 | // Skip problematic entries 42 | continue 43 | } 44 | } 45 | 46 | console.log(`✅ Memory cache warmed up: ${memoryCache.size} entries loaded`) 47 | } catch (error) { 48 | console.error('❌ Error during memory cache warm-up:', error) 49 | } 50 | } 51 | 52 | // Start warm-up process only if IndexedDB is available 53 | if (typeof indexedDB !== 'undefined') { 54 | warmUpMemoryCache() 55 | } 56 | 57 | function addToMemoryCache(url, data) { 58 | // If cache is full, remove oldest item (first item) 59 | if (memoryCache.size >= MAX_MEMORY_CACHE_SIZE) { 60 | const firstKey = memoryCache.keys().next().value 61 | memoryCache.delete(firstKey) 62 | } 63 | 64 | // Add/update item (moves to end if already exists) 65 | memoryCache.delete(url) 66 | memoryCache.set(url, data) 67 | // Log memory cache milestones 68 | if (memoryCache.size % 500 === 0 && memoryCache.size > 0) { 69 | console.log(`[PERF] 📊 Memory cache: ${memoryCache.size} entries`) 70 | } 71 | } 72 | 73 | /** 74 | * Handle cache cleanup when storage is full and retry setting cache object 75 | * @param {string} url - The URL key for caching 76 | * @param {object} cacheObject - The cache object to store 77 | */ 78 | async function handleCacheCleanupAndRetry(url, cacheObject) { 79 | const keysArray = await keys() 80 | console.log(`Cache full (#elements: ${keysArray.length})! Removing elements...`) 81 | try { 82 | // local storage cache full, delete random elements 83 | for (let i = 0; i < CACHE_CONFIG.CLEANUP_BATCH_SIZE; i++) { 84 | // eslint-disable-next-line sonarjs/pseudo-random 85 | const randomStoredUrl = keysArray[Math.floor(Math.random() * keysArray.length)] 86 | del(randomStoredUrl) 87 | } 88 | await set(url, cacheObject) 89 | } catch (error2) { 90 | console.error(`Unable to locally cache information for "${url}": ${error2}`) 91 | } 92 | } 93 | 94 | export async function cachedFetch(url, processData, fetchParameters = {}, noCache = false) { 95 | try { 96 | if (noCache) throw new Error('No cache') 97 | 98 | // Check memory cache first 99 | if (memoryCache.has(url)) { 100 | const cachedData = memoryCache.get(url) 101 | 102 | // Move to end (mark as recently used) 103 | memoryCache.delete(url) 104 | memoryCache.set(url, cachedData) 105 | 106 | processData(cachedData) 107 | return 108 | } 109 | 110 | // Skip IndexedDB operations if not available 111 | if (typeof indexedDB === 'undefined') { 112 | throw new Error('IndexedDB not available') 113 | } 114 | 115 | const cacheObject = await get(url) 116 | 117 | if (cacheObject.timestamp < Date.now() - CACHE_CONFIG.EXPIRY_MS) { 118 | throw new Error('Cached data is too old') 119 | } 120 | 121 | const data = JSON.parse(LZString.decompress(cacheObject.data)) 122 | 123 | if (!data) throw new Error('Cached data is empty') 124 | 125 | // Add to memory cache for future use 126 | addToMemoryCache(url, data) 127 | 128 | processData(data) 129 | } catch { 130 | // Network fetch (cache miss) 131 | try { 132 | const response = await fetch(url, fetchParameters) 133 | 134 | if (!response.ok) { 135 | throw new Error(`Received response with status ${response.status}`) 136 | } 137 | 138 | const data = await response.json() 139 | 140 | const compressedData = LZString.compress(JSON.stringify(data)) 141 | 142 | const cacheObject = { data: compressedData, timestamp: Date.now() } 143 | try { 144 | if (noCache) { 145 | url = url.replace('&noCache=true', '') 146 | } 147 | await set(url, cacheObject) 148 | } catch { 149 | await handleCacheCleanupAndRetry(url, cacheObject) 150 | } 151 | console.log(`Successfully fetched data for "${url}"`) 152 | 153 | // Add to memory cache for future use 154 | addToMemoryCache(url, data) 155 | 156 | processData(data) 157 | } catch (error3) { 158 | console.error(`Unable to fetch or process data for "${url}": ${error3}`) 159 | } 160 | } 161 | } 162 | 163 | export function clearCache() { 164 | if (typeof indexedDB !== 'undefined') { 165 | clear() 166 | } 167 | memoryCache.clear() 168 | console.log('Cleared both IndexedDB and memory cache') 169 | } 170 | --------------------------------------------------------------------------------