├── 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 |
18 | {{
19 | icon
20 | }}
21 |
22 |
23 |
29 |
--------------------------------------------------------------------------------
/src/components/HeaderExternalLinks.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 | mdi-post
10 |
11 |
17 | mdi-github
18 |
19 |
20 |
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 |
11 |
17 |
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 |
14 | {{ interfaceStore.errorToast.message }}
21 |
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 |
30 | {{ icon }}
42 |
43 |
44 |
53 |
--------------------------------------------------------------------------------
/src/components/PublicationComponentSearch.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
27 |
38 |
39 |
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 |
21 |
32 |
33 |
34 |
35 |
36 |
58 |
--------------------------------------------------------------------------------
/src/components/basic/InfoDialog.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
25 |
26 |
27 | {{ modalStore.infoDialog.title }}
28 |
29 |
30 |
31 |
32 | Close
33 |
34 |
35 |
36 |
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 |
19 |
26 |
27 |
28 | {{ modalStore.confirmDialog.title }}
29 |
30 |
31 |
32 |
33 | Cancel
34 | Ok
35 |
36 |
37 |
38 |
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 |
22 |
23 |
24 |
25 |
26 | {{ author.score }}
27 |
28 | {{ author.initials }}
29 |
30 | {{ author.firstAuthorCount }} : {{ author.count }}
31 |
32 |
33 |
34 |
35 |
36 | Aggregated score of {{ author.score }} through {{ author.count }} selected
37 | publication{{ author.count > 1 ? 's' : ''
38 | }}
39 | ({{ author.firstAuthorCount }} all as first author) , published between {{ author.yearMin }} and {{ author.yearMax }} , published {{ author.yearMin }} ( new) .
46 |
47 |
48 |
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 | 
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 |
48 |
54 |
55 |
56 | Share your current session: This link contains your selections and keyword
57 | settings.
58 | Note: Changes require generating a new link.
59 |
60 |
61 |
71 |
72 |
79 | {{ copySuccess ? 'mdi-check' : 'mdi-content-copy' }}
80 | {{ copySuccess ? 'Copied!' : 'Copy' }}
81 |
82 |
83 |
84 |
85 |
86 |
87 | Tip: You can bookmark this link or share it with others to let them view
88 | your current publication selection and settings.
89 |
90 |
91 |
92 |
93 |
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: '' }
44 | }))
45 |
46 | describe('HeaderPanel', () => {
47 | const mockAppMeta = {
48 | nameHtml: 'Pure Suggest',
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: '' },
62 | SessionMenuComponent: { template: '' },
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 |
48 |
55 |
56 |
59 |
60 | {{ icon }}
61 | {{ title }}
62 |
63 |
64 | mdi-close
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
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 |
15 |
21 |
22 |
23 | You can use the following keyboard shortcuts to control the application.
24 | General
25 |
26 | mdi-alpha-s-box-outline Open "Search" dialog
27 | mdi-alpha-b-box-outline Jump to "Boost" input
28 |
29 | mdi-alpha-u-box-outline Update selected publications (if publications
30 | are waiting)
31 |
32 | mdi-alpha-f-box-outline Switch on/off filtering of suggestions
33 |
34 | mdi-alpha-a-box-outline Open list of authors of selected publications
35 |
36 |
37 | mdi-arrow-left-bold-box-outline Jump to first publication of "Selected"
38 |
39 |
40 | mdi-arrow-right-bold-box-outline Jump to first publication of
41 | "Suggested"
42 |
43 |
44 | mdi-arrow-down-bold-box-outline Move one publication down (if one is
45 | active)
46 |
47 |
48 | mdi-arrow-up-bold-box-outline Move one publication up (if one is
49 | active)
50 |
51 | mdi-keyboard-esc Escape input field or active
52 | mdi-alpha-c-box-outline Clear session
53 |
54 | Active Publication
55 | If a publication is marked as active:
56 |
57 | mdi-plus-box-outline Add publication to selection
58 |
59 | mdi-minus-box-outline Exclude publication from selection/suggestion
60 |
61 | mdi-alpha-d-box-outline Open "DOI" link
62 | mdi-alpha-t-box-outline Show abstract
63 | mdi-alpha-g-box-outline Open "Google Scholar" link
64 | mdi-alpha-x-box-outline Export as BibTeX citation
65 | mdi-alpha-i-box-outline Toggle DOI filter
66 |
67 | Citation Network
68 |
69 |
70 | mdi-alpha-m-box-outline Toggle mode of citation network visualization
71 | between timeline and
72 | clusters
73 |
74 |
75 | mdi-alpha-p-box-outline Toggle performance panel for network debugging
76 |
77 |
78 |
79 |
80 |
81 |
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 |
52 |
53 |
61 | Update
62 |
63 |
64 |
71 | mdi-water-outline
72 | Selected
73 |
74 |
81 | mdi-water-plus-outline
82 | Suggested
83 |
84 |
91 | mdi-chart-bubble
92 | Network
93 |
94 |
95 |
96 |
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 |
65 |
71 |
72 |
73 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
88 |
92 |
93 |
98 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
116 |
--------------------------------------------------------------------------------
/src/components/NetworkPerformanceMonitor.vue:
--------------------------------------------------------------------------------
1 |
116 |
117 |
118 |
119 |
120 | SIMULATION SKIPPED
121 | (Empty State)
122 | Network Cleared
123 |
124 |
125 | FPS: {{ currentFps.toFixed(1) }}
126 |
127 | Tick: {{ tickCount
128 | }}{{ shouldSkipEarlyTicks && tickCount <= skipEarlyTicks ? ' (skipping)' : '' }}
129 | Nodes: {{ nodeCount }}
130 |
131 | Links: {{ linkCount }}
132 |
133 | DOM Updates: {{ domUpdateCount }}
134 |
135 | Skipped: {{ skippedUpdateCount }}
136 |
137 | Nodes Updated: {{ lastNodeUpdateCount }}/{{ nodeCount }}
138 |
139 | Links Updated: {{ lastLinkUpdateCount }}/{{ linkCount }}
140 |
141 |
142 |
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 |
41 |
42 |
43 |
51 | mdi-chart-bubble
52 |
Citation network
53 |
54 |
55 | {{ errorMessage }}
56 |
57 |
58 |
59 |
68 | M ode:
69 |
70 | Timeline
72 |
73 | Clusters
76 |
77 |
84 |
91 |
{
97 | if (interfaceStore.isNetworkCollapsed) $emit('restoreNetwork')
98 | $emit('expandNetwork', true)
99 | }
100 | "
101 | class="is-hidden-touch has-text-white"
102 | >
103 |
110 |
111 |
112 |
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 |
75 |
76 |
77 |
83 | mdi-text-box-multiple-outline
84 |
85 |
86 | {{ publicationCountString }}
87 |
88 |
89 | {{ sessionStore.sessionName }} ({{ publicationCountString }})
90 |
91 | mdi-menu-down
92 |
93 |
94 |
95 |
96 |
97 |
98 | {{ publicationCountString }}
99 |
100 |
101 | {{ sessionStore.sessionName }} ({{ publicationCountString }})
102 |
103 |
104 |
105 |
117 |
118 |
124 |
125 |
130 |
135 |
140 |
146 |
151 |
152 |
153 |
154 |
155 |
156 |
161 |
--------------------------------------------------------------------------------
/src/components/HeaderPanel.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 | mdi-water-plus-outline
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | mdi-dots-vertical
35 |
36 |
37 |
38 |
39 |
40 |
41 |
47 |
52 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | {{ appMeta.subtitle }}
65 |
66 |
67 |
68 |
69 |
70 | Based on a set of selected publications,
71 | suggest ing related
72 | pu blications connected by
73 | re ferences.
74 |
75 |
76 |
77 |
78 |
79 |
80 |
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 |
--------------------------------------------------------------------------------