From 0e516f6e563a65cf4ebbe9a9517e409397f954e9 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 1 Apr 2026 15:14:00 +0800 Subject: [PATCH] chore(test): add unit tests and enforce dash-case naming for test files (#3715) Add ~290 new unit tests covering utils (lru, diff, css, a11y, walk, usage, txt-worker), services (EdgeTTSClient, transformers), and suppress noisy console output across all test files. Rename 27 camelCase test files to dash-case for consistency. Co-authored-by: Claude Opus 4.6 (1M context) --- SECURITY.md | 37 + apps/readest-app/.gitignore | 1 + apps/readest-app/package.json | 2 + .../ai/{aiStore.test.ts => ai-store.test.ts} | 0 .../library/library-utils-extended.test.ts | 313 +++++ .../src/__tests__/app/opds/opds-utils.test.ts | 570 +++++++++ .../src/__tests__/app/user/plan.test.ts | 305 +++++ .../src/__tests__/helpers/auth.test.ts | 240 ++++ .../src/__tests__/helpers/open-with.test.ts | 262 ++++ .../src/__tests__/helpers/updater.test.ts | 365 ++++++ .../__tests__/services/app-service.test.ts | 372 ++++++ .../services/backup-service-extended.test.ts | 268 ++++ .../__tests__/services/cloud-service.test.ts | 207 ++++ .../command-registry-extended.test.ts | 481 +++++++ .../src/__tests__/services/constants.test.ts | 1104 +++++++++++++++++ .../services/edge-tts-client.test.ts | 380 ++++++ .../__tests__/services/environment.test.ts | 250 ++++ .../src/__tests__/services/rsvp-utils.test.ts | 243 ++++ .../services/transfer-manager.test.ts | 674 ++++++++++ .../transformers/transformers.test.ts | 798 ++++++++++++ .../services/translators/cache.test.ts | 312 +++++ .../services/translators/polish.test.ts | 152 +++ .../services/translators/providers.test.ts | 318 +++++ .../__tests__/services/tts-controller.test.ts | 666 ++++++++++ .../src/__tests__/services/tts-utils.test.ts | 213 ++++ .../services/web-app-service.test.ts | 271 ++++ .../src/__tests__/store/ai-chat-store.test.ts | 457 +++++++ .../__tests__/store/book-data-store.test.ts | 276 +++++ .../__tests__/store/custom-font-store.test.ts | 389 ++++++ .../store/custom-texture-store.test.ts | 384 ++++++ .../src/__tests__/store/device-store.test.ts | 227 ++++ .../src/__tests__/store/library-store.test.ts | 324 +++++ .../__tests__/store/notebook-store.test.ts | 223 ++++ .../store/parallel-view-store.test.ts | 312 +++++ .../__tests__/store/proofread-store.test.ts | 506 ++++++++ .../src/__tests__/store/reader-store.test.ts | 314 +++++ .../__tests__/store/settings-store.test.ts | 209 ++++ .../src/__tests__/store/sidebar-store.test.ts | 337 +++++ .../src/__tests__/store/theme-store.test.ts | 232 ++++ .../store/traffic-light-store.test.ts | 241 ++++ .../__tests__/store/transfer-store.test.ts | 432 +++++++ .../src/__tests__/styles/fonts.test.ts | 457 +++++++ .../src/__tests__/styles/themes.test.ts | 218 ++++ .../src/__tests__/utils/a11y.test.ts | 179 +++ .../src/__tests__/utils/css.test.ts | 317 +++++ .../src/__tests__/utils/debounce.test.ts | 190 +++ .../src/__tests__/utils/deepl.test.ts | 203 +++ .../src/__tests__/utils/diff.test.ts | 220 ++++ .../src/__tests__/utils/event.test.ts | 201 +++ .../src/__tests__/utils/fetch.test.ts | 197 +++ .../src/__tests__/utils/font.test.ts | 510 ++++++++ .../src/__tests__/utils/image.test.ts | 250 ++++ .../src/__tests__/utils/lang.test.ts | 474 +++++++ .../src/__tests__/utils/lru.test.ts | 471 +++++++ .../src/__tests__/utils/nav.test.ts | 350 ++++++ .../src/__tests__/utils/network.test.ts | 126 ++ .../src/__tests__/utils/sel.test.ts | 446 +++++++ ...tcutKeys.test.ts => shortcut-keys.test.ts} | 0 .../src/__tests__/utils/ssml-extra.test.ts | 149 +++ .../src/__tests__/utils/style-dom.test.ts | 659 ++++++++++ .../__tests__/utils/style-get-styles.test.ts | 669 ++++++++++ .../src/__tests__/utils/style.test.ts | 294 +++++ .../src/__tests__/utils/time.test.ts | 135 ++ .../src/__tests__/utils/txt-worker.test.ts | 162 +++ .../src/__tests__/utils/ua.test.ts | 140 +++ .../src/__tests__/utils/usage.test.ts | 161 +++ .../src/__tests__/utils/validation.test.ts | 566 +++++++++ .../src/__tests__/utils/walk.test.ts | 224 ++++ apps/readest-app/src/services/cloudService.ts | 2 - .../src/store/parallelViewStore.ts | 2 - apps/readest-app/vitest.config.mts | 10 + package.json | 3 +- pnpm-lock.yaml | 116 +- 73 files changed, 21762 insertions(+), 6 deletions(-) create mode 100644 SECURITY.md rename apps/readest-app/src/__tests__/ai/{aiStore.test.ts => ai-store.test.ts} (100%) create mode 100644 apps/readest-app/src/__tests__/app/library/library-utils-extended.test.ts create mode 100644 apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts create mode 100644 apps/readest-app/src/__tests__/app/user/plan.test.ts create mode 100644 apps/readest-app/src/__tests__/helpers/auth.test.ts create mode 100644 apps/readest-app/src/__tests__/helpers/open-with.test.ts create mode 100644 apps/readest-app/src/__tests__/helpers/updater.test.ts create mode 100644 apps/readest-app/src/__tests__/services/app-service.test.ts create mode 100644 apps/readest-app/src/__tests__/services/backup-service-extended.test.ts create mode 100644 apps/readest-app/src/__tests__/services/cloud-service.test.ts create mode 100644 apps/readest-app/src/__tests__/services/command-registry-extended.test.ts create mode 100644 apps/readest-app/src/__tests__/services/constants.test.ts create mode 100644 apps/readest-app/src/__tests__/services/edge-tts-client.test.ts create mode 100644 apps/readest-app/src/__tests__/services/environment.test.ts create mode 100644 apps/readest-app/src/__tests__/services/rsvp-utils.test.ts create mode 100644 apps/readest-app/src/__tests__/services/transfer-manager.test.ts create mode 100644 apps/readest-app/src/__tests__/services/transformers/transformers.test.ts create mode 100644 apps/readest-app/src/__tests__/services/translators/cache.test.ts create mode 100644 apps/readest-app/src/__tests__/services/translators/polish.test.ts create mode 100644 apps/readest-app/src/__tests__/services/translators/providers.test.ts create mode 100644 apps/readest-app/src/__tests__/services/tts-controller.test.ts create mode 100644 apps/readest-app/src/__tests__/services/tts-utils.test.ts create mode 100644 apps/readest-app/src/__tests__/services/web-app-service.test.ts create mode 100644 apps/readest-app/src/__tests__/store/ai-chat-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/book-data-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/custom-font-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/custom-texture-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/device-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/library-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/notebook-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/parallel-view-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/proofread-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/reader-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/settings-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/sidebar-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/theme-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/traffic-light-store.test.ts create mode 100644 apps/readest-app/src/__tests__/store/transfer-store.test.ts create mode 100644 apps/readest-app/src/__tests__/styles/fonts.test.ts create mode 100644 apps/readest-app/src/__tests__/styles/themes.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/a11y.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/css.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/debounce.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/deepl.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/diff.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/event.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/fetch.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/font.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/image.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/lang.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/lru.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/nav.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/network.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/sel.test.ts rename apps/readest-app/src/__tests__/utils/{shortcutKeys.test.ts => shortcut-keys.test.ts} (100%) create mode 100644 apps/readest-app/src/__tests__/utils/ssml-extra.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/style-dom.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/style-get-styles.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/style.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/time.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/txt-worker.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/ua.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/usage.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/validation.test.ts create mode 100644 apps/readest-app/src/__tests__/utils/walk.test.ts diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..2df5be2a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,37 @@ +# Security Policy + +## Supported Versions + +Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series. + +| Version | Supported | +| ------- | ------------------ | +| 0.10.x | :white_check_mark: | +| < 0.10 | :x: | + +## Reporting a Vulnerability + +Please report suspected vulnerabilities privately. Do not open a public GitHub +issue or discussion for security-sensitive reports. + +Use GitHub's private vulnerability reporting for this repository: + + + +When submitting a report, include: + +- A clear description of the issue and the affected component +- Steps to reproduce, proof of concept, or a minimal test case +- The versions, platforms, or environments you tested +- Any suggested remediation or mitigating details, if available + +What to expect after you report: + +- We will aim to acknowledge receipt within 3 business days. +- We may contact you for additional details, reproduction steps, or validation. +- If the report is accepted, we will work on a fix and coordinate disclosure. +- If the report is declined, we will explain why, for example if the behavior is + expected, unsupported, or not reproducible. + +Please keep vulnerability details private until a fix is available and the +maintainers have approved disclosure. diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index b1617c6e..d4a8ec07 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -8,6 +8,7 @@ # testing /coverage +.test-sandbox-node/ # next.js /.next/ diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index c1c9d6c5..1e8eacf8 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -17,6 +17,7 @@ "i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs", "lint": "tsgo --noEmit && biome check .", "test": "dotenv -e .env -e .env.test.local vitest", + "test:coverage": "dotenv -e .env -e .env.test.local -- vitest run --coverage", "test:browser": "vitest --config vitest.browser.config.mts --watch=false", "test:tauri": "bash scripts/test-tauri.sh", "test:pr:web": "pnpm test -- --watch=false && pnpm test:browser", @@ -198,6 +199,7 @@ "@vitejs/plugin-rsc": "^0.5.21", "@vitest/browser-playwright": "^4.0.18", "@vitest/browser-webdriverio": "^4.0.18", + "@vitest/coverage-v8": "^4.0.18", "@wdio/cli": "^9.25.0", "@wdio/globals": "^9.23.0", "@wdio/local-runner": "^9.24.0", diff --git a/apps/readest-app/src/__tests__/ai/aiStore.test.ts b/apps/readest-app/src/__tests__/ai/ai-store.test.ts similarity index 100% rename from apps/readest-app/src/__tests__/ai/aiStore.test.ts rename to apps/readest-app/src/__tests__/ai/ai-store.test.ts diff --git a/apps/readest-app/src/__tests__/app/library/library-utils-extended.test.ts b/apps/readest-app/src/__tests__/app/library/library-utils-extended.test.ts new file mode 100644 index 00000000..06f8f4dd --- /dev/null +++ b/apps/readest-app/src/__tests__/app/library/library-utils-extended.test.ts @@ -0,0 +1,313 @@ +import { describe, it, expect } from 'vitest'; +import { + createBookFilter, + getBreadcrumbs, + getBookSortValue, + compareSortValues, + createBookSorter, +} from '@/app/library/utils/libraryUtils'; +import { Book } from '@/types/book'; +import { LibrarySortByType } from '@/types/settings'; +import { BookMetadata } from '@/libs/document'; + +/** + * Tests for functions NOT covered by the existing library-utils.test.ts: + * - createBookFilter + * - getBreadcrumbs + * - getBookSortValue + * - compareSortValues + * - createBookSorter (additional sort-by cases: Author, Format, Series, Published, default) + */ + +const createMockBook = ( + overrides: Partial & { metadata?: Partial }> = {}, +): Book => ({ + hash: `hash-${Math.random().toString(36).substr(2, 9)}`, + format: 'EPUB', + title: 'Test Book', + author: 'Test Author', + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + metadata: { ...overrides.metadata } as BookMetadata, +}); + +describe('createBookFilter', () => { + it('should return true for all books when queryTerm is null', () => { + const filter = createBookFilter(null); + const book = createMockBook({ title: 'Anything' }); + expect(filter(book)).toBe(true); + }); + + it('should return true for all non-deleted books when queryTerm is empty string', () => { + const filter = createBookFilter(''); + const book = createMockBook({ title: 'Anything' }); + expect(filter(book)).toBe(true); + }); + + it('should match by title', () => { + const filter = createBookFilter('Moby'); + const book = createMockBook({ title: 'Moby Dick' }); + expect(filter(book)).toBe(true); + }); + + it('should match by author', () => { + const filter = createBookFilter('Twain'); + const book = createMockBook({ title: 'Adventures', author: 'Mark Twain' }); + expect(filter(book)).toBe(true); + }); + + it('should match by format', () => { + const filter = createBookFilter('PDF'); + const book = createMockBook({ format: 'PDF', title: 'Some Book' }); + expect(filter(book)).toBe(true); + }); + + it('should match by groupName', () => { + const filter = createBookFilter('fiction'); + const book = createMockBook({ title: 'A Book', groupName: 'Science Fiction' }); + expect(filter(book)).toBe(true); + }); + + it('should match by description in metadata', () => { + const filter = createBookFilter('adventure'); + const book = createMockBook({ + title: 'A Book', + metadata: { description: 'An adventure tale' }, + }); + expect(filter(book)).toBe(true); + }); + + it('should be case-insensitive', () => { + const filter = createBookFilter('moby'); + const book = createMockBook({ title: 'MOBY DICK' }); + expect(filter(book)).toBe(true); + }); + + it('should not match non-matching books', () => { + const filter = createBookFilter('nonexistent'); + const book = createMockBook({ title: 'Real Book', author: 'Real Author' }); + expect(filter(book)).toBeFalsy(); + }); + + it('should return false for deleted books', () => { + const filter = createBookFilter('test'); + const book = createMockBook({ title: 'test book', deletedAt: Date.now() }); + expect(filter(book)).toBe(false); + }); + + it('should handle invalid regex gracefully by doing substring match', () => { + // The string "[invalid" is an invalid regex pattern + const filter = createBookFilter('[invalid'); + const book = createMockBook({ title: 'A book with [invalid text' }); + expect(filter(book)).toBe(true); + }); + + it('should not match when invalid regex query does not match', () => { + const filter = createBookFilter('[invalid'); + const book = createMockBook({ title: 'Normal Title', author: 'Normal Author' }); + expect(filter(book)).toBeFalsy(); + }); + + it('should support regex patterns', () => { + const filter = createBookFilter('^Moby'); + const bookMatch = createMockBook({ title: 'Moby Dick' }); + const bookNoMatch = createMockBook({ title: 'The Moby Dick' }); + expect(filter(bookMatch)).toBeTruthy(); + expect(filter(bookNoMatch)).toBeFalsy(); + }); +}); + +describe('getBreadcrumbs', () => { + it('should return empty array for empty string', () => { + expect(getBreadcrumbs('')).toEqual([]); + }); + + it('should return single breadcrumb for single segment', () => { + const result = getBreadcrumbs('Library'); + expect(result).toEqual([{ name: 'Library', path: 'Library' }]); + }); + + it('should return multiple breadcrumbs for path with separators', () => { + const result = getBreadcrumbs('Library/Fiction/Scifi'); + expect(result).toEqual([ + { name: 'Library', path: 'Library' }, + { name: 'Fiction', path: 'Library/Fiction' }, + { name: 'Scifi', path: 'Library/Fiction/Scifi' }, + ]); + }); + + it('should handle two segments', () => { + const result = getBreadcrumbs('A/B'); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ name: 'A', path: 'A' }); + expect(result[1]).toEqual({ name: 'B', path: 'A/B' }); + }); +}); + +describe('getBookSortValue', () => { + it('should return formatted title for Title sort', () => { + const book = createMockBook({ title: 'The Great Book' }); + const value = getBookSortValue(book, LibrarySortByType.Title); + expect(typeof value).toBe('string'); + expect(value).toBeTruthy(); + }); + + it('should return formatted author for Author sort', () => { + const book = createMockBook({ author: 'Jane Austen' }); + const value = getBookSortValue(book, LibrarySortByType.Author); + expect(typeof value).toBe('string'); + expect(value).toBeTruthy(); + }); + + it('should return updatedAt for Updated sort', () => { + const book = createMockBook({ updatedAt: 12345 }); + expect(getBookSortValue(book, LibrarySortByType.Updated)).toBe(12345); + }); + + it('should return createdAt for Created sort', () => { + const book = createMockBook({ createdAt: 67890 }); + expect(getBookSortValue(book, LibrarySortByType.Created)).toBe(67890); + }); + + it('should return format string for Format sort', () => { + const book = createMockBook({ format: 'PDF' }); + expect(getBookSortValue(book, LibrarySortByType.Format)).toBe('PDF'); + }); + + it('should return published date timestamp for Published sort', () => { + const book = createMockBook({ metadata: { published: '2023-01-15' } }); + const value = getBookSortValue(book, LibrarySortByType.Published); + expect(value).toBe(new Date('2023-01-15').getTime()); + }); + + it('should return 0 for Published sort when no published date', () => { + const book = createMockBook({ metadata: {} }); + expect(getBookSortValue(book, LibrarySortByType.Published)).toBe(0); + }); + + it('should return 0 for Published sort when date is invalid', () => { + const book = createMockBook({ metadata: { published: 'not-a-date' } }); + expect(getBookSortValue(book, LibrarySortByType.Published)).toBe(0); + }); + + it('should return updatedAt for unknown sort type', () => { + const book = createMockBook({ updatedAt: 99999 }); + expect(getBookSortValue(book, 'unknown' as LibrarySortByType)).toBe(99999); + }); +}); + +describe('compareSortValues', () => { + it('should compare two strings using locale comparison', () => { + const result = compareSortValues('Apple', 'Banana', 'en'); + expect(result).toBeLessThan(0); + }); + + it('should compare two numbers', () => { + expect(compareSortValues(10, 20, 'en')).toBeLessThan(0); + expect(compareSortValues(20, 10, 'en')).toBeGreaterThan(0); + expect(compareSortValues(10, 10, 'en')).toBe(0); + }); + + it('should return 0 when types are mismatched', () => { + expect(compareSortValues('hello', 42, 'en')).toBe(0); + expect(compareSortValues(42, 'hello', 'en')).toBe(0); + }); + + it('should handle equal strings', () => { + expect(compareSortValues('same', 'same', 'en')).toBe(0); + }); +}); + +describe('createBookSorter - additional cases', () => { + it('should sort by author with last-name-first formatting', () => { + const books = [ + createMockBook({ author: 'Jane Austen' }), + createMockBook({ author: 'Charles Dickens' }), + ]; + const sorter = createBookSorter(LibrarySortByType.Author, 'en'); + const sorted = [...books].sort(sorter); + // Austen < Dickens (by last name) + expect(sorted[0]!.author).toBe('Jane Austen'); + expect(sorted[1]!.author).toBe('Charles Dickens'); + }); + + it('should sort by createdAt', () => { + const books = [ + createMockBook({ title: 'Newer', createdAt: 2000 }), + createMockBook({ title: 'Older', createdAt: 1000 }), + ]; + const sorter = createBookSorter(LibrarySortByType.Created, 'en'); + const sorted = [...books].sort(sorter); + expect(sorted[0]!.title).toBe('Older'); + expect(sorted[1]!.title).toBe('Newer'); + }); + + it('should sort by format alphabetically', () => { + const books = [createMockBook({ format: 'PDF' }), createMockBook({ format: 'EPUB' })]; + const sorter = createBookSorter(LibrarySortByType.Format, 'en'); + const sorted = [...books].sort(sorter); + expect(sorted[0]!.format).toBe('EPUB'); + expect(sorted[1]!.format).toBe('PDF'); + }); + + it('should sort by seriesIndex', () => { + const books = [ + createMockBook({ metadata: { seriesIndex: 3 } }), + createMockBook({ metadata: { seriesIndex: 1 } }), + createMockBook({ metadata: { seriesIndex: 2 } }), + ]; + const sorter = createBookSorter(LibrarySortByType.Series, 'en'); + const sorted = [...books].sort(sorter); + expect(sorted[0]!.metadata?.seriesIndex).toBe(1); + expect(sorted[1]!.metadata?.seriesIndex).toBe(2); + expect(sorted[2]!.metadata?.seriesIndex).toBe(3); + }); + + it('should sort by published date', () => { + const books = [ + createMockBook({ metadata: { published: '2023-06-15' } }), + createMockBook({ metadata: { published: '2020-01-01' } }), + createMockBook({ metadata: { published: '2021-12-31' } }), + ]; + const sorter = createBookSorter(LibrarySortByType.Published, 'en'); + const sorted = [...books].sort(sorter); + expect(sorted[0]!.metadata?.published).toBe('2020-01-01'); + expect(sorted[1]!.metadata?.published).toBe('2021-12-31'); + expect(sorted[2]!.metadata?.published).toBe('2023-06-15'); + }); + + it('should handle missing published dates in sort (books without date use fallback 0001-01-01)', () => { + const books = [ + createMockBook({ metadata: {} }), + createMockBook({ metadata: { published: '2020-01-01' } }), + ]; + const sorter = createBookSorter(LibrarySortByType.Published, 'en'); + const sorted = [...books].sort(sorter); + // Book without published date defaults to '0001-01-01', which is earlier + expect(sorted[0]!.metadata?.published).toBeUndefined(); + expect(sorted[1]!.metadata?.published).toBe('2020-01-01'); + }); + + it('should handle invalid published dates', () => { + const books = [ + createMockBook({ metadata: { published: 'not-a-date' } }), + createMockBook({ metadata: { published: '2020-01-01' } }), + ]; + const sorter = createBookSorter(LibrarySortByType.Published, 'en'); + const sorted = [...books].sort(sorter); + // Invalid date treated like missing, should sort after valid dates + expect(sorted[0]!.metadata?.published).toBe('2020-01-01'); + }); + + it('should use updatedAt as default sort for unknown sort type', () => { + const books = [ + createMockBook({ title: 'Newer', updatedAt: 2000 }), + createMockBook({ title: 'Older', updatedAt: 1000 }), + ]; + const sorter = createBookSorter('unknown', 'en'); + const sorted = [...books].sort(sorter); + expect(sorted[0]!.title).toBe('Older'); + expect(sorted[1]!.title).toBe('Newer'); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts new file mode 100644 index 00000000..5e61d2e6 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/opds/opds-utils.test.ts @@ -0,0 +1,570 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock dependencies before importing the module under test +vi.mock('foliate-js/opds.js', () => ({ + isOPDSCatalog: vi.fn((type: string) => { + return ( + type.includes('application/atom+xml') && + (type.includes('opds-catalog') || type.includes('opds')) + ); + }), +})); + +vi.mock('@/libs/document', () => ({ + EXTS: { + EPUB: 'epub', + PDF: 'pdf', + MOBI: 'mobi', + AZW: 'azw', + AZW3: 'azw3', + CBZ: 'cbz', + FB2: 'fb2', + FBZ: 'fbz', + TXT: 'txt', + MD: 'md', + }, +})); + +vi.mock('@/services/environment', () => ({ + isWebAppPlatform: vi.fn(() => true), + isTauriAppPlatform: vi.fn(() => false), + getAPIBaseUrl: () => '/api', + getNodeAPIBaseUrl: () => '/node-api', + getBaseUrl: () => 'https://web.readest.com', + getNodeBaseUrl: () => 'https://node.readest.com', + isWebDevMode: () => true, +})); + +vi.mock('@tauri-apps/plugin-http', () => ({ + fetch: vi.fn(), +})); + +vi.mock('@/app/opds/utils/opdsReq', () => ({ + fetchWithAuth: vi.fn(), +})); + +import { + groupByArray, + parseMediaType, + isSearchLink, + resolveURL, + getFileExtFromPath, + MIME, + validateOPDSURL, +} from '@/app/opds/utils/opdsUtils'; +import type { OPDSLink } from '@/types/opds'; +import { fetchWithAuth } from '@/app/opds/utils/opdsReq'; + +const mockFetchWithAuth = vi.mocked(fetchWithAuth); + +describe('opdsUtils', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('groupByArray', () => { + it('should group items by a single key', () => { + const items = [ + { name: 'Alice', role: 'admin' }, + { name: 'Bob', role: 'user' }, + { name: 'Charlie', role: 'admin' }, + ]; + const grouped = groupByArray(items, (item) => item.role); + + expect(grouped.get('admin')).toHaveLength(2); + expect(grouped.get('user')).toHaveLength(1); + expect(grouped.get('admin')![0]!.name).toBe('Alice'); + expect(grouped.get('admin')![1]!.name).toBe('Charlie'); + }); + + it('should group items by multiple keys (array return)', () => { + const items = [ + { name: 'Alice', tags: ['a', 'b'] }, + { name: 'Bob', tags: ['b', 'c'] }, + ]; + const grouped = groupByArray(items, (item) => item.tags); + + expect(grouped.get('a')).toHaveLength(1); + expect(grouped.get('b')).toHaveLength(2); + expect(grouped.get('c')).toHaveLength(1); + }); + + it('should return an empty map for undefined input', () => { + const grouped = groupByArray(undefined, (item: string) => item); + expect(grouped.size).toBe(0); + }); + + it('should return an empty map for empty array', () => { + const grouped = groupByArray([], (item: string) => item); + expect(grouped.size).toBe(0); + }); + + it('should handle single-element arrays', () => { + const items = [{ id: 1 }]; + const grouped = groupByArray(items, (item) => item.id); + expect(grouped.get(1)).toHaveLength(1); + }); + }); + + describe('MIME constants', () => { + it('should have correct XML MIME type', () => { + expect(MIME.XML).toBe('application/xml'); + }); + + it('should have correct ATOM MIME type', () => { + expect(MIME.ATOM).toBe('application/atom+xml'); + }); + + it('should have correct XHTML MIME type', () => { + expect(MIME.XHTML).toBe('application/xhtml+xml'); + }); + + it('should have correct HTML MIME type', () => { + expect(MIME.HTML).toBe('text/html'); + }); + + it('should have correct EPUB MIME type', () => { + expect(MIME.EPUB).toBe('application/epub+zip'); + }); + + it('should have correct PDF MIME type', () => { + expect(MIME.PDF).toBe('application/pdf'); + }); + + it('should have correct OPENSEARCH MIME type', () => { + expect(MIME.OPENSEARCH).toBe('application/opensearchdescription+xml'); + }); + }); + + describe('parseMediaType', () => { + it('should parse a simple media type', () => { + const result = parseMediaType('application/atom+xml'); + expect(result).not.toBeNull(); + expect(result!.mediaType).toBe('application/atom+xml'); + expect(Object.keys(result!.parameters)).toHaveLength(0); + }); + + it('should parse media type with parameters', () => { + const result = parseMediaType('application/atom+xml; charset=utf-8'); + expect(result).not.toBeNull(); + expect(result!.mediaType).toBe('application/atom+xml'); + expect(result!.parameters['charset']).toBe('utf-8'); + }); + + it('should parse media type with multiple parameters', () => { + const result = parseMediaType('text/html; charset=utf-8; boundary=something'); + expect(result).not.toBeNull(); + expect(result!.mediaType).toBe('text/html'); + expect(result!.parameters['charset']).toBe('utf-8'); + expect(result!.parameters['boundary']).toBe('something'); + }); + + it('should parse media type with quoted parameter values', () => { + const result = parseMediaType('application/atom+xml; profile="opds-catalog"'); + expect(result).not.toBeNull(); + expect(result!.parameters['profile']).toBe('opds-catalog'); + }); + + it('should lowercase the media type', () => { + const result = parseMediaType('Application/Atom+XML'); + expect(result).not.toBeNull(); + expect(result!.mediaType).toBe('application/atom+xml'); + }); + + it('should lowercase parameter names', () => { + const result = parseMediaType('text/html; Charset=utf-8'); + expect(result).not.toBeNull(); + expect(result!.parameters['charset']).toBe('utf-8'); + }); + + it('should return null for empty string', () => { + expect(parseMediaType('')).toBeNull(); + }); + + it('should return null for undefined input', () => { + expect(parseMediaType(undefined)).toBeNull(); + }); + + it('should handle extra spaces around semicolons', () => { + const result = parseMediaType('text/html ; charset=utf-8'); + expect(result).not.toBeNull(); + expect(result!.mediaType).toBe('text/html'); + expect(result!.parameters['charset']).toBe('utf-8'); + }); + }); + + describe('isSearchLink', () => { + it('should return true for a search link with OPENSEARCH type', () => { + const link: OPDSLink = { + rel: 'search', + href: '/search', + type: MIME.OPENSEARCH, + properties: {}, + }; + expect(isSearchLink(link)).toBe(true); + }); + + it('should return true for a search link with ATOM type', () => { + const link: OPDSLink = { + rel: 'search', + href: '/search', + type: MIME.ATOM, + properties: {}, + }; + expect(isSearchLink(link)).toBe(true); + }); + + it('should return true when rel is an array containing "search"', () => { + const link: OPDSLink = { + rel: ['self', 'search'], + href: '/search', + type: MIME.OPENSEARCH, + properties: {}, + }; + expect(isSearchLink(link)).toBe(true); + }); + + it('should return false when rel does not include "search"', () => { + const link: OPDSLink = { + rel: 'self', + href: '/search', + type: MIME.OPENSEARCH, + properties: {}, + }; + expect(isSearchLink(link)).toBe(false); + }); + + it('should return false when type is not OPENSEARCH or ATOM', () => { + const link: OPDSLink = { + rel: 'search', + href: '/search', + type: 'text/html', + properties: {}, + }; + expect(isSearchLink(link)).toBe(false); + }); + + it('should return false when rel is undefined', () => { + const link: OPDSLink = { + href: '/search', + type: MIME.OPENSEARCH, + properties: {}, + }; + expect(isSearchLink(link)).toBe(false); + }); + + it('should return false when rel is an empty array', () => { + const link: OPDSLink = { + rel: [], + href: '/search', + type: MIME.ATOM, + properties: {}, + }; + expect(isSearchLink(link)).toBe(false); + }); + }); + + describe('resolveURL', () => { + it('should resolve an absolute URL relative to a base URL', () => { + const result = resolveURL('/feed/new', 'https://example.com/opds'); + expect(result).toBe('https://example.com/feed/new'); + }); + + it('should resolve a relative URL relative to a base URL', () => { + const result = resolveURL('new', 'https://example.com/opds/feed'); + expect(result).toBe('https://example.com/opds/new'); + }); + + it('should return empty string for empty url', () => { + expect(resolveURL('', 'https://example.com')).toBe(''); + }); + + it('should resolve through proxy URL', () => { + const proxyBase = '/api/opds/proxy?url=https%3A%2F%2Fexample.com%2Fopds'; + const result = resolveURL('/feed/new', proxyBase); + expect(result).toBe('https://example.com/feed/new'); + }); + + it('should handle non-scheme relativeTo (path-only)', () => { + const result = resolveURL('subdir/file.xml', '/opds/catalog/'); + // Should use the invalid.invalid root and strip it + expect(result).toContain('subdir/file.xml'); + expect(result).not.toContain('invalid.invalid'); + }); + + it('should return the URL itself when resolution fails', () => { + // Suppress console.warn for this test + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = resolveURL('://broken', '://also-broken'); + // Should fall through to the catch and return url + expect(result).toBe('://broken'); + warnSpy.mockRestore(); + }); + + it('should handle full absolute URLs as the url parameter', () => { + const result = resolveURL('https://other.com/feed', 'https://example.com/opds'); + expect(result).toBe('https://other.com/feed'); + }); + + it('should strip query parameters for non-scheme relativeTo', () => { + const result = resolveURL('feed.xml?page=2', '/opds/catalog/'); + // The function strips search params for non-scheme relativeTo + expect(result).not.toContain('page=2'); + }); + }); + + describe('getFileExtFromPath', () => { + it('should find epub extension in path', () => { + expect(getFileExtFromPath('/books/epub/my-book')).toBe('epub'); + }); + + it('should find pdf extension in path', () => { + expect(getFileExtFromPath('/books/pdf/my-doc')).toBe('pdf'); + }); + + it('should find mobi extension in path', () => { + expect(getFileExtFromPath('/books/mobi/my-book')).toBe('mobi'); + }); + + it('should find cbz extension in path', () => { + expect(getFileExtFromPath('/comics/cbz/issue1')).toBe('cbz'); + }); + + it('should find fb2 extension in path', () => { + expect(getFileExtFromPath('/books/fb2/russian-novel')).toBe('fb2'); + }); + + it('should find txt extension in path', () => { + expect(getFileExtFromPath('/texts/txt/readme')).toBe('txt'); + }); + + it('should find md extension in path', () => { + expect(getFileExtFromPath('/notes/md/guide')).toBe('md'); + }); + + it('should return empty string when no extension found', () => { + expect(getFileExtFromPath('/books/unknown/my-book')).toBe(''); + }); + + it('should return empty string for empty path', () => { + expect(getFileExtFromPath('')).toBe(''); + }); + + it('should use custom delimiter', () => { + expect(getFileExtFromPath('books.epub.my-book', '.')).toBe('epub'); + }); + + it('should return the first matching extension', () => { + // If both epub and pdf appear, returns whichever EXTS entry matches first + const result = getFileExtFromPath('/books/epub/pdf/file'); + expect(['epub', 'pdf']).toContain(result); + }); + }); + + describe('validateOPDSURL', () => { + beforeEach(() => { + mockFetchWithAuth.mockReset(); + // Suppress expected error noise from validation failure tests. + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return valid feed for XML OPDS feed', async () => { + const xmlFeed = ` + + Test Feed +`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/opds', + text: () => Promise.resolve(xmlFeed), + headers: new Headers({ 'Content-Type': 'application/atom+xml' }), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds'); + expect(result.isValid).toBe(true); + expect(result.data?.type).toBe('feed'); + expect(result.data?.responseURL).toBe('https://example.com/opds'); + }); + + it('should return valid entry for XML entry document', async () => { + const xmlEntry = ` + + Single Book +`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/opds/entry', + text: () => Promise.resolve(xmlEntry), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds/entry'); + expect(result.isValid).toBe(true); + expect(result.data?.type).toBe('entry'); + }); + + it('should return valid opensearch for OpenSearchDescription', async () => { + const xmlOSD = ` + + Search +`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/search', + text: () => Promise.resolve(xmlOSD), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/search'); + expect(result.isValid).toBe(true); + expect(result.data?.type).toBe('opensearch'); + }); + + it('should return authentication error for 401 status', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + url: 'https://example.com/opds', + text: () => Promise.resolve(''), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds'); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Authentication required'); + }); + + it('should return error for non-OK HTTP response', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + url: 'https://example.com/opds', + text: () => Promise.resolve(''), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds'); + expect(result.isValid).toBe(false); + expect(result.error).toContain('500'); + }); + + it('should return valid feed for JSON-based OPDS', async () => { + const jsonFeed = JSON.stringify({ + metadata: { title: 'Test Feed' }, + links: [], + publications: [], + }); + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/opds.json', + text: () => Promise.resolve(jsonFeed), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds.json'); + expect(result.isValid).toBe(true); + expect(result.data?.type).toBe('feed'); + }); + + it('should return invalid for JSON without OPDS fields', async () => { + const jsonData = JSON.stringify({ foo: 'bar' }); + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/data.json', + text: () => Promise.resolve(jsonData), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/data.json'); + expect(result.isValid).toBe(false); + }); + + it('should return invalid for non-XML, non-JSON content', async () => { + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/page', + text: () => Promise.resolve('Just some plain text'), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/page'); + expect(result.isValid).toBe(false); + }); + + it('should handle fetch errors gracefully', async () => { + mockFetchWithAuth.mockRejectedValue(new Error('Network error')); + + const result = await validateOPDSURL('https://example.com/opds'); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should handle non-Error exceptions', async () => { + mockFetchWithAuth.mockRejectedValue('string error'); + + const result = await validateOPDSURL('https://example.com/opds'); + expect(result.isValid).toBe(false); + }); + + it('should return valid for JSON feed with only navigation', async () => { + const jsonFeed = JSON.stringify({ + navigation: [{ title: 'New', href: '/new' }], + }); + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/opds.json', + text: () => Promise.resolve(jsonFeed), + headers: new Headers(), + } as Response); + + const result = await validateOPDSURL('https://example.com/opds.json'); + expect(result.isValid).toBe(true); + }); + + it('should return invalid for XML that is not a recognized OPDS document type', async () => { + const xmlDoc = ` + + Test +`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/catalog', + text: () => Promise.resolve(xmlDoc), + headers: new Headers({ 'Content-Type': 'application/xml' }), + } as Response); + + const result = await validateOPDSURL('https://example.com/catalog'); + expect(result.isValid).toBe(false); + }); + + it('should pass username and password to fetchWithAuth', async () => { + const xmlFeed = `Auth Feed`; + + mockFetchWithAuth.mockResolvedValue({ + ok: true, + url: 'https://example.com/opds', + text: () => Promise.resolve(xmlFeed), + headers: new Headers(), + } as Response); + + await validateOPDSURL('https://example.com/opds', 'user', 'pass', true); + + expect(mockFetchWithAuth).toHaveBeenCalledWith( + 'https://example.com/opds', + 'user', + 'pass', + true, + expect.objectContaining({ signal: expect.anything() }), + ); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/app/user/plan.test.ts b/apps/readest-app/src/__tests__/app/user/plan.test.ts new file mode 100644 index 00000000..a6dc87c4 --- /dev/null +++ b/apps/readest-app/src/__tests__/app/user/plan.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect } from 'vitest'; +import { getPlanDetails } from '@/app/user/utils/plan'; +import { AvailablePlan, UserPlan, PlanInterval, QuotaFeature } from '@/types/quota'; +import { StripeProductMetadata } from '@/types/payment'; + +// getPlanDetails expects (AvailablePlan & StripeAvailablePlan)[]. +// StripeAvailablePlan = AvailablePlan & { metadata?: StripeProductMetadata; product?: Stripe.Product } +// We build the exact shape needed to satisfy the type. + +type TestPlan = AvailablePlan & { + metadata?: StripeProductMetadata; +}; + +function makePlan(overrides: Partial = {}): TestPlan { + return { + plan: 'free' as UserPlan, + productId: 'prod_test', + price: 0, + currency: 'USD', + interval: 'month' as PlanInterval, + productName: 'Test Plan', + ...overrides, + }; +} + +describe('getPlanDetails', () => { + describe('free plan', () => { + it('should return free plan details with no available plans', () => { + const result = getPlanDetails('free', []); + expect(result.name).toBe('Free Plan'); + expect(result.plan).toBe('free'); + expect(result.type).toBe('subscription'); + expect(result.price).toBe(0); + expect(result.currency).toBe('USD'); + }); + + it('should use currency from available plans', () => { + const plans = [makePlan({ plan: 'free', currency: 'EUR' })]; + const result = getPlanDetails('free', plans); + expect(result.currency).toBe('EUR'); + }); + + it('should include features array', () => { + const result = getPlanDetails('free', []); + expect(result.features.length).toBeGreaterThan(0); + const labels = result.features.map((f) => f.label); + expect(labels).toContain('Cross-Platform Sync'); + expect(labels).toContain('AI Read Aloud'); + }); + + it('should include limits', () => { + const result = getPlanDetails('free', []); + expect(result.limits).toBeDefined(); + expect(Object.keys(result.limits!).length).toBeGreaterThan(0); + }); + + it('should use month interval label by default', () => { + const result = getPlanDetails('free', []); + expect(result.interval).toBe('month'); + }); + + it('should use year interval label when specified', () => { + const result = getPlanDetails('free', [], 'year'); + expect(result.interval).toBe('year'); + }); + + it('should set productId from matching available plan', () => { + const plans = [makePlan({ plan: 'free', productId: 'prod_free_123' })]; + const result = getPlanDetails('free', plans); + expect(result.productId).toBe('prod_free_123'); + }); + }); + + describe('plus plan', () => { + it('should return plus plan details', () => { + const plans = [makePlan({ plan: 'plus', price: 499 })]; + const result = getPlanDetails('plus', plans); + expect(result.name).toBe('Plus Plan'); + expect(result.plan).toBe('plus'); + expect(result.type).toBe('subscription'); + expect(result.price).toBe(499); + }); + + it('should use default price when no matching plan found', () => { + const result = getPlanDetails('plus', []); + expect(result.price).toBe(499); + }); + + it('should include expected features', () => { + const result = getPlanDetails('plus', []); + const labels = result.features.map((f) => f.label); + expect(labels).toContain('Includes All Free Plan Benefits'); + expect(labels).toContain('Unlimited AI Read Aloud Hours'); + expect(labels).toContain('Priority Support'); + }); + + it('should include limits with storage and translation', () => { + const result = getPlanDetails('plus', []); + expect(result.limits).toBeDefined(); + const limitKeys = Object.keys(result.limits!); + expect(limitKeys.some((k) => k.includes('Storage'))).toBe(true); + expect(limitKeys.some((k) => k.includes('Translation'))).toBe(true); + }); + + it('should match correct plan by interval', () => { + const plans = [ + makePlan({ plan: 'plus', price: 499, interval: 'month' }), + makePlan({ plan: 'plus', price: 3999, interval: 'year' }), + ]; + const monthResult = getPlanDetails('plus', plans, 'month'); + expect(monthResult.price).toBe(499); + + const yearResult = getPlanDetails('plus', plans, 'year'); + expect(yearResult.price).toBe(3999); + }); + }); + + describe('pro plan', () => { + it('should return pro plan details', () => { + const plans = [makePlan({ plan: 'pro', price: 999 })]; + const result = getPlanDetails('pro', plans); + expect(result.name).toBe('Pro Plan'); + expect(result.plan).toBe('pro'); + expect(result.type).toBe('subscription'); + expect(result.price).toBe(999); + }); + + it('should use default price when no matching plan found', () => { + const result = getPlanDetails('pro', []); + expect(result.price).toBe(999); + }); + + it('should include expected features', () => { + const result = getPlanDetails('pro', []); + const labels = result.features.map((f) => f.label); + expect(labels).toContain('Includes All Plus Plan Benefits'); + expect(labels).toContain('Early Feature Access'); + expect(labels).toContain('Advanced AI Tools'); + }); + + it('should have higher storage limit than plus', () => { + const proResult = getPlanDetails('pro', []); + const plusResult = getPlanDetails('plus', []); + + // Both should have limits + expect(proResult.limits).toBeDefined(); + expect(plusResult.limits).toBeDefined(); + + // Find storage limit values + const proStorageKey = Object.keys(proResult.limits!).find((k) => k.includes('Storage')); + const plusStorageKey = Object.keys(plusResult.limits!).find((k) => k.includes('Storage')); + expect(proStorageKey).toBeDefined(); + expect(plusStorageKey).toBeDefined(); + + // Pro should have 20 GB, Plus should have 5 GB + expect(proResult.limits![proStorageKey!]).toBe('20 GB'); + expect(plusResult.limits![plusStorageKey!]).toBe('5 GB'); + }); + }); + + describe('purchase plan', () => { + it('should return purchase plan details', () => { + const plans = [makePlan({ plan: 'purchase', price: 1999 })]; + const result = getPlanDetails('purchase', plans); + expect(result.name).toBe('Lifetime Plan'); + expect(result.plan).toBe('purchase'); + expect(result.type).toBe('purchase'); + expect(result.interval).toBe('lifetime'); + }); + + it('should use default price when no matching plan found', () => { + const result = getPlanDetails('purchase', []); + expect(result.price).toBe(1999); + }); + + it('should include products sorted by price', () => { + const plans = [ + makePlan({ + plan: 'purchase', + productId: 'prod_expensive', + price: 5000, + productName: 'Expensive', + }), + makePlan({ plan: 'purchase', productId: 'prod_cheap', price: 1000, productName: 'Cheap' }), + makePlan({ plan: 'purchase', productId: 'prod_mid', price: 3000, productName: 'Mid' }), + ]; + const result = getPlanDetails('purchase', plans); + expect(result.products).toBeDefined(); + expect(result.products).toHaveLength(3); + expect(result.products![0]!.price).toBe(1000); + expect(result.products![1]!.price).toBe(3000); + expect(result.products![2]!.price).toBe(5000); + }); + + it('should derive feature from productId when metadata.feature is missing', () => { + const plans = [ + makePlan({ + plan: 'purchase', + productId: 'prod_storage_100gb', + price: 1000, + productName: 'Storage', + }), + makePlan({ + plan: 'purchase', + productId: 'prod_translation_pack', + price: 500, + productName: 'Translation', + }), + makePlan({ + plan: 'purchase', + productId: 'prod_tokens_bundle', + price: 2000, + productName: 'Tokens', + }), + makePlan({ + plan: 'purchase', + productId: 'prod_customization_pro', + price: 1500, + productName: 'Custom', + }), + ]; + const result = getPlanDetails('purchase', plans); + expect(result.products![0]!.feature).toBe('translation'); + expect(result.products![1]!.feature).toBe('storage'); + expect(result.products![2]!.feature).toBe('customization'); + expect(result.products![3]!.feature).toBe('tokens'); + }); + + it('should use metadata.feature when present', () => { + const plans = [ + makePlan({ + plan: 'purchase', + productId: 'prod_xyz', + price: 1000, + productName: 'XYZ', + metadata: { plan: 'purchase', feature: 'storage' as QuotaFeature }, + }), + ]; + const result = getPlanDetails('purchase', plans); + expect(result.products![0]!.feature).toBe('storage'); + }); + + it('should fall back to generic when productId does not match any feature', () => { + const plans = [ + makePlan({ + plan: 'purchase', + productId: 'prod_unknown_thing', + price: 1000, + productName: 'Unknown', + }), + ]; + const result = getPlanDetails('purchase', plans); + expect(result.products![0]!.feature).toBe('generic'); + }); + + it('should include expected features', () => { + const result = getPlanDetails('purchase', []); + const labels = result.features.map((f) => f.label); + expect(labels).toContain('One-Time Payment'); + expect(labels).toContain('Expand Cloud Sync Storage'); + }); + }); + + describe('default / unknown plan', () => { + it('should fall back to free plan for unknown plan code', () => { + const result = getPlanDetails('unknown_plan' as UserPlan, []); + expect(result.plan).toBe('free'); + expect(result.name).toBe('Free Plan'); + }); + }); + + describe('plan color and hintColor', () => { + it('should assign distinct colors per plan', () => { + const colors: Record = {}; + const planCodes: UserPlan[] = ['free', 'plus', 'pro', 'purchase']; + for (const code of planCodes) { + const details = getPlanDetails(code, []); + colors[code] = details.color; + } + // Each plan should have a unique color + const uniqueColors = new Set(Object.values(colors)); + expect(uniqueColors.size).toBe(4); + }); + + it('should provide hintColor for all plans', () => { + const planCodes: UserPlan[] = ['free', 'plus', 'pro', 'purchase']; + for (const code of planCodes) { + const details = getPlanDetails(code, []); + expect(details.hintColor).toBeTruthy(); + } + }); + }); + + describe('plan without interval match', () => { + it('should handle plan with no interval specified', () => { + // A plan with no interval should match any interval request + const plans = [ + makePlan({ plan: 'purchase', price: 1999, interval: undefined as unknown as PlanInterval }), + ]; + // When interval is not set on available plan, it should still be found + const result = getPlanDetails('purchase', plans); + expect(result.price).toBe(1999); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/helpers/auth.test.ts b/apps/readest-app/src/__tests__/helpers/auth.test.ts new file mode 100644 index 00000000..b9d17763 --- /dev/null +++ b/apps/readest-app/src/__tests__/helpers/auth.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { User, AuthError } from '@supabase/supabase-js'; + +// Mock supabase before importing the module under test +const mockSetSession = vi.fn(); +const mockGetUser = vi.fn(); + +vi.mock('@/utils/supabase', () => ({ + supabase: { + auth: { + setSession: (...args: unknown[]) => mockSetSession(...args), + getUser: () => mockGetUser(), + }, + }, +})); + +import { handleAuthCallback } from '@/helpers/auth'; + +describe('handleAuthCallback', () => { + let mockLogin: ReturnType void>>; + let mockNavigate: ReturnType void>>; + + const fakeUser: User = { + id: 'user-123', + app_metadata: {}, + user_metadata: {}, + aud: 'authenticated', + created_at: '2024-01-01T00:00:00Z', + } as User; + + beforeEach(() => { + mockLogin = vi.fn<(accessToken: string, user: User) => void>(); + mockNavigate = vi.fn<(path: string) => void>(); + mockSetSession.mockReset(); + mockGetUser.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should navigate to /auth/error when error is present', async () => { + handleAuthCallback({ + accessToken: 'token', + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + error: 'some_error', + }); + + // Wait for the async finalizeSession to complete + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/auth/error'); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + expect(mockSetSession).not.toHaveBeenCalled(); + }); + + it('should navigate to /library when accessToken is missing', async () => { + handleAuthCallback({ + accessToken: null, + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/library'); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('should navigate to /library when refreshToken is missing', async () => { + handleAuthCallback({ + accessToken: 'token', + refreshToken: null, + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/library'); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('should navigate to /library when both tokens are missing', async () => { + handleAuthCallback({ + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/library'); + }); + }); + + it('should navigate to /auth/error when setSession fails', async () => { + const sessionError: AuthError = { + message: 'Invalid token', + name: 'AuthError', + status: 401, + } as AuthError; + + mockSetSession.mockResolvedValue({ error: sessionError }); + + handleAuthCallback({ + accessToken: 'bad-token', + refreshToken: 'bad-refresh', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/auth/error'); + }); + + expect(mockSetSession).toHaveBeenCalledWith({ + access_token: 'bad-token', + refresh_token: 'bad-refresh', + }); + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('should login and navigate to next URL on successful auth', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: fakeUser } }); + + handleAuthCallback({ + accessToken: 'good-token', + refreshToken: 'good-refresh', + login: mockLogin, + navigate: mockNavigate, + next: '/reader', + }); + + await vi.waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('good-token', fakeUser); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/reader'); + }); + + it('should default next to "/" when not specified', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: fakeUser } }); + + handleAuthCallback({ + accessToken: 'token', + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/'); + }); + }); + + it('should navigate to /auth/recovery when type is "recovery"', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: fakeUser } }); + + handleAuthCallback({ + accessToken: 'token', + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + type: 'recovery', + next: '/some-page', + }); + + await vi.waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('token', fakeUser); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/auth/recovery'); + // Should NOT navigate to next when type is recovery + expect(mockNavigate).not.toHaveBeenCalledWith('/some-page'); + }); + + it('should navigate to /auth/error when getUser returns null user', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: null } }); + + handleAuthCallback({ + accessToken: 'token', + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/auth/error'); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('should not call login when user is undefined from getUser', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: undefined } }); + + handleAuthCallback({ + accessToken: 'token', + refreshToken: 'refresh', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/auth/error'); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + }); + + it('should pass the correct session parameters to setSession', async () => { + mockSetSession.mockResolvedValue({ error: null }); + mockGetUser.mockResolvedValue({ data: { user: fakeUser } }); + + handleAuthCallback({ + accessToken: 'my-access-token', + refreshToken: 'my-refresh-token', + login: mockLogin, + navigate: mockNavigate, + }); + + await vi.waitFor(() => { + expect(mockSetSession).toHaveBeenCalledWith({ + access_token: 'my-access-token', + refresh_token: 'my-refresh-token', + }); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/helpers/open-with.test.ts b/apps/readest-app/src/__tests__/helpers/open-with.test.ts new file mode 100644 index 00000000..dfd12938 --- /dev/null +++ b/apps/readest-app/src/__tests__/helpers/open-with.test.ts @@ -0,0 +1,262 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; + +// ── Mocks ──────────────────────────────────────────────────────── +let mockIsWebAppPlatform = false; +let mockHasCli = false; + +vi.mock('@/services/environment', () => ({ + isWebAppPlatform: () => mockIsWebAppPlatform, + hasCli: () => mockHasCli, +})); + +const mockGetCurrent = vi.fn<() => Promise>(); +vi.mock('@tauri-apps/plugin-deep-link', () => ({ + getCurrent: () => mockGetCurrent(), +})); + +const mockGetMatches = vi.fn(); +vi.mock('@tauri-apps/plugin-cli', () => ({ + getMatches: () => mockGetMatches(), +})); + +import { parseOpenWithFiles } from '@/helpers/openWith'; + +// Helper type matching the AppService subset used in openWith +interface MockAppService { + isIOSApp: boolean; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Suppress expected console noise from parseIntentOpenWithFiles. + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); + mockIsWebAppPlatform = false; + mockHasCli = false; + // Reset window globals + delete window.OPEN_WITH_FILES; + // Reset location.search + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '' }, + writable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('parseOpenWithFiles', () => { + // ── Web platform ─────────────────────────────────────────────── + describe('web platform', () => { + test('returns empty array on web platform', async () => { + mockIsWebAppPlatform = true; + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual([]); + }); + }); + + // ── Window URL params ────────────────────────────────────────── + describe('window URL params', () => { + test('parses file params from URL search', async () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?file=book1.epub&file=book2.epub' }, + writable: true, + }); + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['book1.epub', 'book2.epub']); + }); + + test('uses window.OPEN_WITH_FILES when no URL params', async () => { + window.OPEN_WITH_FILES = ['/path/to/book.epub']; + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/path/to/book.epub']); + }); + + test('prefers URL params over OPEN_WITH_FILES', async () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?file=url-book.epub' }, + writable: true, + }); + window.OPEN_WITH_FILES = ['/path/to/window-book.epub']; + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['url-book.epub']); + }); + }); + + // ── CLI arguments ────────────────────────────────────────────── + describe('CLI arguments', () => { + test('parses files from CLI matches', async () => { + mockHasCli = true; + mockGetMatches.mockResolvedValue({ + args: { + file1: { value: '/path/file1.epub', occurrences: 1 }, + file2: { value: '/path/file2.epub', occurrences: 1 }, + file3: { value: '', occurrences: 0 }, + file4: { value: '', occurrences: 0 }, + }, + }); + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/path/file1.epub', '/path/file2.epub']); + }); + + test('returns empty array when CLI has no file args', async () => { + mockHasCli = true; + mockGetMatches.mockResolvedValue({ + args: { + file1: { value: '', occurrences: 0 }, + file2: { value: '', occurrences: 0 }, + file3: { value: '', occurrences: 0 }, + file4: { value: '', occurrences: 0 }, + }, + }); + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + // Falls through to intent, which returns null + expect(result).toBeNull(); + }); + + test('skips CLI parsing when hasCli is false', async () => { + mockHasCli = false; + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(mockGetMatches).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + test('handles null args from CLI', async () => { + mockHasCli = true; + mockGetMatches.mockResolvedValue({ args: null }); + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toBeNull(); + }); + }); + + // ── Intent / Deep Link ──────────────────────────────────────── + describe('intent open with files', () => { + test('parses file:// URLs', async () => { + mockGetCurrent.mockResolvedValue(['file:///path/to/book.epub']); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/path/to/book.epub']); + }); + + test('preserves file:// prefix for iOS', async () => { + const mockAppService = { isIOSApp: true } as MockAppService; + mockGetCurrent.mockResolvedValue(['file:///path/to/book.epub']); + + const result = await parseOpenWithFiles(mockAppService as never); + + expect(result).toEqual(['file:///path/to/book.epub']); + }); + + test('handles content:// URLs (Android)', async () => { + mockGetCurrent.mockResolvedValue(['content://com.example/book.epub']); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['content://com.example/book.epub']); + }); + + test('filters out non-file, non-content URLs', async () => { + mockGetCurrent.mockResolvedValue([ + 'file:///path/book.epub', + 'https://example.com/book.epub', + 'content://com.example/book2.epub', + ]); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/path/book.epub', 'content://com.example/book2.epub']); + }); + + test('decodes URI-encoded file paths', async () => { + mockGetCurrent.mockResolvedValue(['file:///path/to/my%20book.epub']); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/path/to/my book.epub']); + }); + + test('returns null when no deep link URLs', async () => { + mockGetCurrent.mockResolvedValue(null); + + const result = await parseOpenWithFiles(null); + + expect(result).toBeNull(); + }); + + test('returns null when deep link returns empty array', async () => { + mockGetCurrent.mockResolvedValue([]); + + const result = await parseOpenWithFiles(null); + + expect(result).toBeNull(); + }); + }); + + // ── Priority / fallthrough ───────────────────────────────────── + describe('fallthrough logic', () => { + test('uses window params first, skips CLI and intent', async () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?file=from-url.epub' }, + writable: true, + }); + mockHasCli = true; + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['from-url.epub']); + expect(mockGetMatches).not.toHaveBeenCalled(); + expect(mockGetCurrent).not.toHaveBeenCalled(); + }); + + test('falls through from empty window params to CLI', async () => { + mockHasCli = true; + mockGetMatches.mockResolvedValue({ + args: { + file1: { value: '/cli-file.epub', occurrences: 1 }, + file2: { value: '', occurrences: 0 }, + file3: { value: '', occurrences: 0 }, + file4: { value: '', occurrences: 0 }, + }, + }); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/cli-file.epub']); + expect(mockGetCurrent).not.toHaveBeenCalled(); + }); + + test('falls through from empty window params and no CLI to intent', async () => { + mockHasCli = false; + mockGetCurrent.mockResolvedValue(['file:///intent-file.epub']); + + const result = await parseOpenWithFiles(null); + + expect(result).toEqual(['/intent-file.epub']); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/helpers/updater.test.ts b/apps/readest-app/src/__tests__/helpers/updater.test.ts new file mode 100644 index 00000000..6d5a701a --- /dev/null +++ b/apps/readest-app/src/__tests__/helpers/updater.test.ts @@ -0,0 +1,365 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import semver from 'semver'; + +// ── Mocks for Tauri and internal modules ───────────────────────── +const mockCheck = vi.fn(); +const mockOsType = vi.fn(); +const mockTauriFetch = vi.fn(); + +vi.mock('@tauri-apps/plugin-updater', () => ({ + check: (...args: unknown[]) => mockCheck(...args), +})); + +vi.mock('@tauri-apps/plugin-os', () => ({ + type: () => mockOsType(), +})); + +vi.mock('@tauri-apps/plugin-http', () => ({ + fetch: (...args: unknown[]) => mockTauriFetch(...args), +})); + +// WebviewWindow is used with `new`, so the mock must be a constructor +const mockWebviewWindowOnce = vi.fn(); +const MockWebviewWindowLastArgs: unknown[][] = []; +vi.mock('@tauri-apps/api/webviewWindow', () => { + return { + WebviewWindow: class MockWebviewWindow { + once = mockWebviewWindowOnce; + constructor(...args: unknown[]) { + MockWebviewWindowLastArgs.push(args); + } + }, + }; +}); + +const mockSetUpdaterWindowVisible = vi.fn(); +vi.mock('@/components/UpdaterWindow', () => ({ + setUpdaterWindowVisible: (...args: unknown[]) => mockSetUpdaterWindowVisible(...args), +})); + +let mockIsTauriAppPlatform = false; +vi.mock('@/services/environment', () => ({ + isTauriAppPlatform: () => mockIsTauriAppPlatform, +})); + +let mockAppVersion = '1.0.0'; +vi.mock('@/utils/version', () => ({ + getAppVersion: () => mockAppVersion, +})); + +vi.mock('@/services/constants', () => ({ + CHECK_UPDATE_INTERVAL_SEC: 86400, + READEST_UPDATER_FILE: 'https://example.com/latest.json', + READEST_CHANGELOG_FILE: 'https://example.com/release-notes.json', +})); + +import { + checkForAppUpdates, + checkAppReleaseNotes, + setLastShownReleaseNotesVersion, + getLastShownReleaseNotesVersion, +} from '@/helpers/updater'; + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockIsTauriAppPlatform = false; + mockAppVersion = '1.0.0'; + MockWebviewWindowLastArgs.length = 0; + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Helper to create a dummy TranslationFunc ───────────────────── +const dummyTranslate = (key: string) => key; + +describe('updater', () => { + // ── setLastShownReleaseNotesVersion / getLastShownReleaseNotesVersion ── + describe('release notes version tracking', () => { + test('getLastShownReleaseNotesVersion returns empty string when not set', () => { + expect(getLastShownReleaseNotesVersion()).toBe(''); + }); + + test('setLastShownReleaseNotesVersion stores value in localStorage', () => { + setLastShownReleaseNotesVersion('2.0.0'); + expect(getLastShownReleaseNotesVersion()).toBe('2.0.0'); + }); + + test('overwrites previous value', () => { + setLastShownReleaseNotesVersion('1.0.0'); + setLastShownReleaseNotesVersion('2.0.0'); + expect(getLastShownReleaseNotesVersion()).toBe('2.0.0'); + }); + }); + + // ── checkForAppUpdates ───────────────────────────────────────── + describe('checkForAppUpdates', () => { + test('skips check when auto-check and interval has not elapsed', async () => { + const now = Date.now(); + localStorage.setItem('lastAppUpdateCheck', now.toString()); + + const result = await checkForAppUpdates(dummyTranslate, true); + + expect(result).toBe(false); + expect(mockCheck).not.toHaveBeenCalled(); + }); + + test('proceeds with check when interval has elapsed', async () => { + const pastTime = Date.now() - 86400 * 1000 - 1000; + localStorage.setItem('lastAppUpdateCheck', pastTime.toString()); + + mockOsType.mockReturnValue('macos'); + mockCheck.mockResolvedValue(null); + + const result = await checkForAppUpdates(dummyTranslate, true); + + expect(result).toBe(false); + expect(mockCheck).toHaveBeenCalled(); + }); + + test('proceeds when no previous check timestamp', async () => { + mockOsType.mockReturnValue('macos'); + mockCheck.mockResolvedValue(null); + + const result = await checkForAppUpdates(dummyTranslate, true); + + expect(result).toBe(false); + expect(mockCheck).toHaveBeenCalled(); + }); + + test('always checks when isAutoCheck is false', async () => { + const now = Date.now(); + localStorage.setItem('lastAppUpdateCheck', now.toString()); + + mockOsType.mockReturnValue('macos'); + mockCheck.mockResolvedValue(null); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(false); + expect(mockCheck).toHaveBeenCalled(); + }); + + test('returns true and shows update window when update available on macOS', async () => { + mockOsType.mockReturnValue('macos'); + mockCheck.mockResolvedValue({ version: '2.0.0' }); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(true); + expect(MockWebviewWindowLastArgs).toHaveLength(1); + expect(MockWebviewWindowLastArgs[0]![0]).toBe('updater'); + expect(MockWebviewWindowLastArgs[0]![1]).toEqual( + expect.objectContaining({ + url: '/updater?latestVersion=2.0.0', + title: 'Software Update', + }), + ); + }); + + test('handles windows platform', async () => { + mockOsType.mockReturnValue('windows'); + mockCheck.mockResolvedValue(null); + + const result = await checkForAppUpdates(dummyTranslate, false); + expect(result).toBe(false); + }); + + test('handles linux platform', async () => { + mockOsType.mockReturnValue('linux'); + mockCheck.mockResolvedValue({ version: '3.0.0' }); + + const result = await checkForAppUpdates(dummyTranslate, false); + expect(result).toBe(true); + }); + + test('checks Android update via fetch when OS is android', async () => { + mockOsType.mockReturnValue('android'); + mockAppVersion = '1.0.0'; + + mockTauriFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + version: '2.0.0', + platforms: { 'android-arm64': {} }, + }), + }); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(true); + expect(mockSetUpdaterWindowVisible).toHaveBeenCalledWith(true, '2.0.0', '1.0.0'); + }); + + test('Android check with android-universal platform', async () => { + mockOsType.mockReturnValue('android'); + mockAppVersion = '1.0.0'; + + mockTauriFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + version: '2.0.0', + platforms: { 'android-universal': {} }, + }), + }); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(true); + expect(mockSetUpdaterWindowVisible).toHaveBeenCalled(); + }); + + test('Android returns false when version is not newer', async () => { + mockOsType.mockReturnValue('android'); + mockAppVersion = '2.0.0'; + + mockTauriFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + version: '1.0.0', + platforms: { 'android-arm64': {} }, + }), + }); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(false); + expect(mockSetUpdaterWindowVisible).not.toHaveBeenCalled(); + }); + + test('Android fetch failure throws error', async () => { + mockOsType.mockReturnValue('android'); + + mockTauriFetch.mockRejectedValue(new Error('Network error')); + + await expect(checkForAppUpdates(dummyTranslate, false)).rejects.toThrow( + 'Failed to fetch Android update info', + ); + }); + + test('returns false for unsupported OS types', async () => { + mockOsType.mockReturnValue('ios'); + + const result = await checkForAppUpdates(dummyTranslate, false); + + expect(result).toBe(false); + }); + + test('stores check timestamp in localStorage', async () => { + mockOsType.mockReturnValue('macos'); + mockCheck.mockResolvedValue(null); + + const before = Date.now(); + await checkForAppUpdates(dummyTranslate, false); + const after = Date.now(); + + const stored = parseInt(localStorage.getItem('lastAppUpdateCheck')!, 10); + expect(stored).toBeGreaterThanOrEqual(before); + expect(stored).toBeLessThanOrEqual(after); + }); + }); + + // ── checkAppReleaseNotes ─────────────────────────────────────── + describe('checkAppReleaseNotes', () => { + test('shows release notes when current version is newer than last shown', async () => { + mockAppVersion = '2.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + const mockFetchFn = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetchFn); + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(true); + expect(mockSetUpdaterWindowVisible).toHaveBeenCalledWith(true, '2.0.0', '1.0.0', false); + }); + + test('returns false when current version equals last shown', async () => { + mockAppVersion = '1.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(false); + }); + + test('sets current version as last shown when no previous version recorded', async () => { + mockAppVersion = '1.5.0'; + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(false); + expect(getLastShownReleaseNotesVersion()).toBe('1.5.0'); + }); + + test('shows release notes when isAutoCheck is false regardless of version comparison', async () => { + mockAppVersion = '1.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + const mockFetchFn = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', mockFetchFn); + + const result = await checkAppReleaseNotes(false); + + expect(result).toBe(true); + expect(mockSetUpdaterWindowVisible).toHaveBeenCalled(); + }); + + test('returns false when fetch fails', async () => { + mockAppVersion = '2.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + const mockFetchFn = vi.fn().mockRejectedValue(new Error('Network error')); + vi.stubGlobal('fetch', mockFetchFn); + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(false); + }); + + test('returns false when fetch response is not ok', async () => { + mockAppVersion = '2.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + const mockFetchFn = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal('fetch', mockFetchFn); + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(false); + }); + + test('uses tauri fetch when on tauri platform', async () => { + mockIsTauriAppPlatform = true; + mockAppVersion = '2.0.0'; + setLastShownReleaseNotesVersion('1.0.0'); + + mockTauriFetch.mockResolvedValue({ ok: true }); + + const result = await checkAppReleaseNotes(true); + + expect(result).toBe(true); + expect(mockTauriFetch).toHaveBeenCalledWith('https://example.com/release-notes.json'); + }); + }); + + // ── semver usage validation ──────────────────────────────────── + describe('semver gt (sanity checks for the logic used)', () => { + test('2.0.0 is greater than 1.0.0', () => { + expect(semver.gt('2.0.0', '1.0.0')).toBe(true); + }); + + test('1.0.0 is not greater than 2.0.0', () => { + expect(semver.gt('1.0.0', '2.0.0')).toBe(false); + }); + + test('equal versions return false', () => { + expect(semver.gt('1.0.0', '1.0.0')).toBe(false); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/app-service.test.ts b/apps/readest-app/src/__tests__/services/app-service.test.ts new file mode 100644 index 00000000..768e2548 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/app-service.test.ts @@ -0,0 +1,372 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BaseDir, FileSystem, ResolvedPath } from '@/types/system'; +import { DatabaseOpts, DatabaseService } from '@/types/database'; +import { SchemaType } from '@/services/database/migrate'; + +// Mock all service dependencies +vi.mock('@/services/settingsService', () => ({ + getDefaultViewSettings: vi.fn().mockReturnValue({ theme: 'light' }), + loadSettings: vi.fn().mockResolvedValue({ + localBooksDir: 'books-dir', + migrationVersion: 99999999, + }), + saveSettings: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/libraryService', () => ({ + loadLibraryBooks: vi.fn().mockResolvedValue([{ title: 'Book1' }]), + saveLibraryBooks: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/bookService', () => ({ + getCoverImageUrl: vi.fn().mockReturnValue('cover-url'), + getCoverImageBlobUrl: vi.fn().mockResolvedValue('blob:cover'), + getCachedImageUrl: vi.fn().mockResolvedValue('cached:url'), + generateCoverImageUrl: vi.fn().mockResolvedValue('generated:url'), + updateCoverImage: vi.fn().mockResolvedValue(undefined), + importBook: vi.fn().mockResolvedValue(null), + exportBook: vi.fn().mockResolvedValue(true), + refreshBookMetadata: vi.fn().mockResolvedValue(true), + isBookAvailable: vi.fn().mockResolvedValue(true), + getBookFileSize: vi.fn().mockResolvedValue(1024), + loadBookContent: vi.fn().mockResolvedValue({ html: '

test

' }), + loadBookConfig: vi.fn().mockResolvedValue({ updatedAt: 0 }), + fetchBookDetails: vi.fn().mockResolvedValue({ title: 'Test' }), + saveBookConfig: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/cloudService', () => ({ + deleteBook: vi.fn().mockResolvedValue(undefined), + uploadFileToCloud: vi.fn().mockResolvedValue('url'), + uploadBook: vi.fn().mockResolvedValue(undefined), + downloadCloudFile: vi.fn().mockResolvedValue(undefined), + downloadBookCovers: vi.fn().mockResolvedValue(undefined), + downloadBook: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/fontService', () => ({ + importFont: vi.fn().mockResolvedValue({ name: 'Font', path: '/f' }), + deleteFont: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/services/imageService', () => ({ + importImage: vi.fn().mockResolvedValue({ id: 'img', url: '/img' }), + deleteImage: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/utils/book', () => ({ + getLibraryFilename: vi.fn().mockReturnValue('library.json'), + getLibraryBackupFilename: vi.fn().mockReturnValue('library_backup.json'), +})); + +vi.mock('@/utils/misc', async (importOriginal) => { + const original = await importOriginal>(); + return { + ...original, + getOSPlatform: vi.fn().mockReturnValue('macos'), + }; +}); + +import { BaseAppService } from '@/services/appService'; +import * as Settings from '@/services/settingsService'; +import * as BookSvc from '@/services/bookService'; + +// Concrete test implementation of BaseAppService +class TestAppService extends BaseAppService { + fs: FileSystem; + + constructor(fs: FileSystem) { + super(); + this.fs = fs; + } + + resolvePath(fp: string, base: BaseDir): ResolvedPath { + return this.fs.resolvePath(fp, base); + } + + async init() { + await this.loadSettings(); + } + + async setCustomRootDir(_dir: string) {} + + async selectDirectory(): Promise { + return '/test/dir'; + } + + async selectFiles(): Promise { + return ['/test/file.epub']; + } + + async saveFile(): Promise { + return true; + } + + async ask(): Promise { + return true; + } + + async openDatabase( + _schema: SchemaType, + _path: string, + _base: BaseDir, + _opts?: DatabaseOpts, + ): Promise { + return {} as DatabaseService; + } +} + +function createMockFs(): FileSystem { + return { + resolvePath: vi.fn().mockReturnValue({ + baseDir: 0, + basePrefix: async () => '/base', + fp: 'path', + base: 'Books' as BaseDir, + }), + getURL: vi.fn().mockReturnValue('url'), + getBlobURL: vi.fn().mockResolvedValue('blob:url'), + getImageURL: vi.fn().mockResolvedValue('image:url'), + openFile: vi.fn().mockResolvedValue(new File(['content'], 'test.epub')), + copyFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('content'), + writeFile: vi.fn().mockResolvedValue(undefined), + removeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([{ path: 'a.txt', size: 10 }]), + createDir: vi.fn().mockResolvedValue(undefined), + removeDir: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(true), + stats: vi.fn().mockResolvedValue({ + isFile: true, + isDirectory: false, + size: 100, + mtime: null, + atime: null, + birthtime: null, + }), + getPrefix: vi.fn().mockResolvedValue('/base/books'), + }; +} + +describe('BaseAppService', () => { + let service: TestAppService; + let mockFs: FileSystem; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs = createMockFs(); + service = new TestAppService(mockFs); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('default properties', () => { + test('has correct default platform flags', () => { + expect(service.appPlatform).toBe('tauri'); + expect(service.isMobile).toBe(false); + expect(service.isMacOSApp).toBe(false); + expect(service.isAndroidApp).toBe(false); + expect(service.isIOSApp).toBe(false); + expect(service.isDesktopApp).toBe(false); + expect(service.hasTrafficLight).toBe(false); + expect(service.hasWindow).toBe(false); + expect(service.hasHaptics).toBe(false); + expect(service.hasUpdater).toBe(false); + expect(service.isEink).toBe(false); + expect(service.localBooksDir).toBe(''); + }); + }); + + describe('prepareBooksDir', () => { + test('sets localBooksDir from fs.getPrefix', async () => { + await service.prepareBooksDir(); + expect(mockFs.getPrefix).toHaveBeenCalledWith('Books'); + expect(service.localBooksDir).toBe('/base/books'); + }); + }); + + describe('file operations', () => { + test('openFile delegates to fs', async () => { + await service.openFile('test.epub', 'Books'); + expect(mockFs.openFile).toHaveBeenCalledWith('test.epub', 'Books'); + }); + + test('copyFile delegates to fs', async () => { + await service.copyFile('src.epub', 'dst.epub', 'Books'); + expect(mockFs.copyFile).toHaveBeenCalledWith('src.epub', 'dst.epub', 'Books'); + }); + + test('readFile delegates to fs', async () => { + const result = await service.readFile('test.txt', 'Data', 'text'); + expect(mockFs.readFile).toHaveBeenCalledWith('test.txt', 'Data', 'text'); + expect(result).toBe('content'); + }); + + test('writeFile delegates to fs', async () => { + await service.writeFile('test.txt', 'Data', 'hello'); + expect(mockFs.writeFile).toHaveBeenCalledWith('test.txt', 'Data', 'hello'); + }); + + test('createDir delegates to fs', async () => { + await service.createDir('newdir', 'Books'); + expect(mockFs.createDir).toHaveBeenCalledWith('newdir', 'Books', true); + }); + + test('createDir respects recursive parameter', async () => { + await service.createDir('newdir', 'Books', false); + expect(mockFs.createDir).toHaveBeenCalledWith('newdir', 'Books', false); + }); + + test('deleteFile delegates to fs.removeFile', async () => { + await service.deleteFile('old.txt', 'Data'); + expect(mockFs.removeFile).toHaveBeenCalledWith('old.txt', 'Data'); + }); + + test('deleteDir delegates to fs.removeDir', async () => { + await service.deleteDir('olddir', 'Books'); + expect(mockFs.removeDir).toHaveBeenCalledWith('olddir', 'Books', true); + }); + + test('deleteDir respects recursive parameter', async () => { + await service.deleteDir('olddir', 'Books', false); + expect(mockFs.removeDir).toHaveBeenCalledWith('olddir', 'Books', false); + }); + + test('exists delegates to fs', async () => { + const result = await service.exists('test.txt', 'Data'); + expect(mockFs.exists).toHaveBeenCalledWith('test.txt', 'Data'); + expect(result).toBe(true); + }); + + test('readDirectory delegates to fs.readDir', async () => { + const result = await service.readDirectory('dir', 'Books'); + expect(mockFs.readDir).toHaveBeenCalledWith('dir', 'Books'); + expect(result).toEqual([{ path: 'a.txt', size: 10 }]); + }); + + test('getImageURL delegates to fs', async () => { + const result = await service.getImageURL('/img.png'); + expect(mockFs.getImageURL).toHaveBeenCalledWith('/img.png'); + expect(result).toBe('image:url'); + }); + }); + + describe('resolveFilePath', () => { + test('combines prefix with path', async () => { + const result = await service.resolveFilePath('test.json', 'Data'); + expect(result).toBe('/base/books/test.json'); + }); + + test('returns just prefix when path is empty', async () => { + const result = await service.resolveFilePath('', 'Data'); + expect(result).toBe('/base/books'); + }); + }); + + describe('settings', () => { + test('loadSettings delegates and updates localBooksDir', async () => { + const settings = await service.loadSettings(); + expect(Settings.loadSettings).toHaveBeenCalled(); + expect(settings.localBooksDir).toBe('books-dir'); + expect(service.localBooksDir).toBe('books-dir'); + }); + + test('saveSettings delegates to settingsService', async () => { + const settings = { localBooksDir: 'dir' } as Parameters[0]; + await service.saveSettings(settings); + expect(Settings.saveSettings).toHaveBeenCalledWith(mockFs, settings); + }); + + test('getDefaultViewSettings delegates', () => { + const result = service.getDefaultViewSettings(); + expect(Settings.getDefaultViewSettings).toHaveBeenCalled(); + expect(result).toEqual({ theme: 'light' }); + }); + }); + + describe('cover image operations', () => { + const mockBook = { hash: 'h1', title: 'Book' } as Parameters< + typeof service.getCoverImageUrl + >[0]; + + test('getCoverImageUrl delegates', () => { + const result = service.getCoverImageUrl(mockBook); + expect(BookSvc.getCoverImageUrl).toHaveBeenCalled(); + expect(result).toBe('cover-url'); + }); + + test('getCoverImageBlobUrl delegates', async () => { + const result = await service.getCoverImageBlobUrl(mockBook); + expect(BookSvc.getCoverImageBlobUrl).toHaveBeenCalled(); + expect(result).toBe('blob:cover'); + }); + + test('getCachedImageUrl delegates', async () => { + const result = await service.getCachedImageUrl('http://img.com/x.png'); + expect(BookSvc.getCachedImageUrl).toHaveBeenCalled(); + expect(result).toBe('cached:url'); + }); + + test('generateCoverImageUrl delegates', async () => { + const result = await service.generateCoverImageUrl(mockBook); + expect(BookSvc.generateCoverImageUrl).toHaveBeenCalled(); + expect(result).toBe('generated:url'); + }); + }); + + describe('library operations', () => { + test('loadLibraryBooks delegates', async () => { + const result = await service.loadLibraryBooks(); + expect(result).toEqual([{ title: 'Book1' }]); + }); + + test('saveLibraryBooks delegates', async () => { + await service.saveLibraryBooks([]); + }); + }); + + describe('runMigrations', () => { + test('runs migration when version is lower', async () => { + vi.mocked(mockFs.exists).mockResolvedValue(true); + vi.mocked(mockFs.readFile).mockResolvedValue('library data'); + + // Access protected method through init -> loadSettings -> runMigrations flow + vi.mocked(Settings.loadSettings).mockResolvedValue({ + localBooksDir: 'dir', + migrationVersion: 0, + } as ReturnType extends Promise ? T : never); + + // Create a service that calls runMigrations in init + class MigratingService extends TestAppService { + override async init() { + await this.loadSettings(); + await this.runMigrations(0); + } + } + + const svc = new MigratingService(mockFs); + await svc.init(); + + // Migration should have checked for the old backup file + expect(mockFs.exists).toHaveBeenCalledWith('library_backup.json', 'Books'); + }); + + test('skips migration when version is current', async () => { + class MigratingService extends TestAppService { + override async init() { + await this.runMigrations(99999999); + } + } + + const svc = new MigratingService(mockFs); + await svc.init(); + + // Should not have tried to read the backup file + expect(mockFs.readFile).not.toHaveBeenCalledWith('library_backup.json', 'Books', 'text'); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/backup-service-extended.test.ts b/apps/readest-app/src/__tests__/services/backup-service-extended.test.ts new file mode 100644 index 00000000..d171abcb --- /dev/null +++ b/apps/readest-app/src/__tests__/services/backup-service-extended.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect } from 'vitest'; +import { + mergeBookConfigs, + mergeBookMetadata, + validateBackupStructure, +} from '@/services/backupService'; +import { Book, BookConfig, BookNote } from '@/types/book'; + +/** + * Extended tests for backupService covering: + * - validateBackupStructure + * - mergeBookConfigs edge cases (empty configs, no booknotes, notes with zero/undefined updatedAt) + * - mergeBookMetadata edge cases (equal timestamps, undefined deletedAt) + */ + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'abc123', + format: 'EPUB', + title: 'Test Book', + author: 'Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +function makeNote(overrides: Partial = {}): BookNote { + return { + id: 'note-1', + type: 'annotation', + cfi: 'cfi-1', + note: 'test note', + createdAt: 100, + updatedAt: 100, + ...overrides, + }; +} + +describe('validateBackupStructure', () => { + it('should return true when library.json is present', () => { + expect(validateBackupStructure(['library.json', 'abc123/book.epub'])).toBe(true); + }); + + it('should return false when library.json is missing', () => { + expect(validateBackupStructure(['abc123/book.epub', 'abc123/config.json'])).toBe(false); + }); + + it('should return false for empty entries', () => { + expect(validateBackupStructure([])).toBe(false); + }); + + it('should not match partial names like library.json.bak', () => { + expect(validateBackupStructure(['library.json.bak'])).toBe(false); + }); + + it('should not match subdirectory library.json', () => { + // Only exact match counts; 'subdir/library.json' !== 'library.json' + expect(validateBackupStructure(['subdir/library.json'])).toBe(false); + }); + + it('should return true even with many other entries', () => { + const entries = Array.from({ length: 100 }, (_, i) => `hash${i}/book.epub`); + entries.push('library.json'); + expect(validateBackupStructure(entries)).toBe(true); + }); +}); + +describe('mergeBookConfigs - extended', () => { + it('should handle both configs having zero progress', () => { + const current: BookConfig = { progress: [0, 200], updatedAt: 100 }; + const backup: BookConfig = { progress: [0, 200], updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + // When progress is equal (both 0), current wins (backupPage > currentPage is false) + expect(result.progress).toEqual([0, 200]); + }); + + it('should handle configs with no progress at all', () => { + const current: Partial = { updatedAt: 100 }; + const backup: Partial = { updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + // Both progress[0] default to 0, so current wins + expect(result.booknotes).toEqual([]); + }); + + it('should merge notes from current when backup has none', () => { + const note = makeNote({ id: 'c1', note: 'current-only' }); + const current: BookConfig = { booknotes: [note], updatedAt: 100 }; + const backup: BookConfig = { updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + expect(result.booknotes).toHaveLength(1); + expect(result.booknotes![0]!.id).toBe('c1'); + }); + + it('should merge notes from backup when current has none', () => { + const note = makeNote({ id: 'b1', note: 'backup-only' }); + const current: BookConfig = { updatedAt: 100 }; + const backup: BookConfig = { booknotes: [note], updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + expect(result.booknotes).toHaveLength(1); + expect(result.booknotes![0]!.id).toBe('b1'); + }); + + it('should handle notes with updatedAt of 0', () => { + const currentNote = makeNote({ id: '1', note: 'current', updatedAt: 0 }); + const backupNote = makeNote({ id: '1', note: 'backup', updatedAt: 0 }); + const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 }; + const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 }; + const result = mergeBookConfigs(current, backup); + // When both are 0, backup note doesn't win ((0 || 0) > (0 || 0) is false) + expect(result.booknotes).toHaveLength(1); + expect(result.booknotes![0]!.note).toBe('current'); + }); + + it('should handle notes with undefined updatedAt (treated as 0)', () => { + const currentNote = makeNote({ + id: '1', + note: 'current', + updatedAt: undefined as unknown as number, + }); + const backupNote = makeNote({ id: '1', note: 'backup', updatedAt: 50 }); + const current: BookConfig = { booknotes: [currentNote], updatedAt: 100 }; + const backup: BookConfig = { booknotes: [backupNote], updatedAt: 100 }; + const result = mergeBookConfigs(current, backup); + // Backup note has updatedAt 50, current has undefined (treated as 0) + // (50 || 0) > (undefined || 0) => 50 > 0 => true, backup wins + expect(result.booknotes).toHaveLength(1); + expect(result.booknotes![0]!.note).toBe('backup'); + }); + + it('should merge many notes from both sides without duplicates', () => { + const currentNotes = Array.from({ length: 5 }, (_, i) => + makeNote({ id: `note-${i}`, note: `current-${i}`, updatedAt: 100 }), + ); + const backupNotes = Array.from({ length: 5 }, (_, i) => + makeNote({ id: `note-${i + 3}`, note: `backup-${i + 3}`, updatedAt: 200 }), + ); + // Overlapping ids: note-3, note-4 (exist in both) + const current: BookConfig = { booknotes: currentNotes, updatedAt: 100 }; + const backup: BookConfig = { booknotes: backupNotes, updatedAt: 100 }; + const result = mergeBookConfigs(current, backup); + + // Total unique ids: note-0..note-7 = 8 + expect(result.booknotes).toHaveLength(8); + + // Overlapping notes should use backup version (higher updatedAt) + const note3 = result.booknotes!.find((n) => n.id === 'note-3'); + expect(note3!.note).toBe('backup-3'); + const note4 = result.booknotes!.find((n) => n.id === 'note-4'); + expect(note4!.note).toBe('backup-4'); + + // Non-overlapping from current should be preserved + const note0 = result.booknotes!.find((n) => n.id === 'note-0'); + expect(note0!.note).toBe('current-0'); + }); + + it('should preserve location from config with higher progress', () => { + const current: BookConfig = { progress: [10, 200], location: 'loc-A', updatedAt: 100 }; + const backup: BookConfig = { progress: [20, 200], location: 'loc-B', updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + expect(result.location).toBe('loc-B'); // backup has higher progress + }); + + it('should preserve location from current when current has higher progress', () => { + const current: BookConfig = { progress: [30, 200], location: 'loc-A', updatedAt: 100 }; + const backup: BookConfig = { progress: [20, 200], location: 'loc-B', updatedAt: 90 }; + const result = mergeBookConfigs(current, backup); + expect(result.location).toBe('loc-A'); + }); + + it('should not mutate the original configs', () => { + const currentNote = makeNote({ id: '1', note: 'original' }); + const current: BookConfig = { booknotes: [currentNote], progress: [10, 200], updatedAt: 100 }; + const backup: BookConfig = { progress: [20, 200], updatedAt: 90 }; + const currentCopy = JSON.parse(JSON.stringify(current)) as BookConfig; + const backupCopy = JSON.parse(JSON.stringify(backup)) as BookConfig; + + mergeBookConfigs(current, backup); + + // Original objects should not be mutated + expect(current.booknotes).toHaveLength(1); + expect(JSON.stringify(current)).toBe(JSON.stringify(currentCopy)); + expect(JSON.stringify(backup)).toBe(JSON.stringify(backupCopy)); + }); +}); + +describe('mergeBookMetadata - extended', () => { + it('should handle equal updatedAt timestamps', () => { + const current = makeBook({ updatedAt: 2000, title: 'Current Title' }); + const backup = makeBook({ updatedAt: 2000, title: 'Backup Title' }); + const result = mergeBookMetadata(current, backup); + // When equal, backup.updatedAt > current.updatedAt is false, so current wins + expect(result.title).toBe('Current Title'); + expect(result.updatedAt).toBe(2000); + }); + + it('should handle equal createdAt timestamps', () => { + const current = makeBook({ createdAt: 1000 }); + const backup = makeBook({ createdAt: 1000 }); + const result = mergeBookMetadata(current, backup); + expect(result.createdAt).toBe(1000); + }); + + it('should handle deletedAt being undefined (treated like null)', () => { + const current = makeBook({ deletedAt: undefined }); + const backup = makeBook({ deletedAt: 5000 }); + const result = mergeBookMetadata(current, backup); + // Only deleted if BOTH sides agree; undefined is falsy + expect(result.deletedAt).toBeNull(); + }); + + it('should handle both deletedAt being undefined', () => { + const current = makeBook({ deletedAt: undefined }); + const backup = makeBook({ deletedAt: undefined }); + const result = mergeBookMetadata(current, backup); + expect(result.deletedAt).toBeNull(); + }); + + it('should handle deletedAt being 0 (falsy number)', () => { + const current = makeBook({ deletedAt: 0 }); + const backup = makeBook({ deletedAt: 5000 }); + const result = mergeBookMetadata(current, backup); + // 0 is falsy, so current.deletedAt && backup.deletedAt is falsy + expect(result.deletedAt).toBeNull(); + }); + + it('should preserve other fields from the base (higher updatedAt)', () => { + const current = makeBook({ + updatedAt: 1000, + hash: 'hash1', + format: 'EPUB', + author: 'Author A', + }); + const backup = makeBook({ + updatedAt: 3000, + hash: 'hash1', + format: 'PDF', + author: 'Author B', + }); + const result = mergeBookMetadata(current, backup); + // Backup has higher updatedAt, so its fields are base + expect(result.format).toBe('PDF'); + expect(result.author).toBe('Author B'); + }); + + it('should reconcile timestamps correctly when backup is older', () => { + const current = makeBook({ updatedAt: 5000, createdAt: 500 }); + const backup = makeBook({ updatedAt: 3000, createdAt: 200 }); + const result = mergeBookMetadata(current, backup); + expect(result.updatedAt).toBe(5000); // max + expect(result.createdAt).toBe(200); // min + }); + + it('should reconcile timestamps correctly when current is older', () => { + const current = makeBook({ updatedAt: 1000, createdAt: 100 }); + const backup = makeBook({ updatedAt: 5000, createdAt: 500 }); + const result = mergeBookMetadata(current, backup); + expect(result.updatedAt).toBe(5000); // max + expect(result.createdAt).toBe(100); // min + }); + + it('should handle both sides deleted with equal timestamps', () => { + const current = makeBook({ deletedAt: 4000 }); + const backup = makeBook({ deletedAt: 4000 }); + const result = mergeBookMetadata(current, backup); + expect(result.deletedAt).toBe(4000); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/cloud-service.test.ts b/apps/readest-app/src/__tests__/services/cloud-service.test.ts new file mode 100644 index 00000000..56f471f7 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/cloud-service.test.ts @@ -0,0 +1,207 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { deleteBook } from '@/services/cloudService'; +import { Book, BookFormat } from '@/types/book'; +import { FileSystem } from '@/types/system'; + +// Mock external dependencies +vi.mock('@/utils/book', () => ({ + getDir: vi.fn((book: Book) => book.hash), + getLocalBookFilename: vi.fn((book: Book) => `${book.hash}/${book.title}.epub`), + getRemoteBookFilename: vi.fn((book: Book) => `${book.hash}/${book.hash}.epub`), + getCoverFilename: vi.fn((book: Book) => `${book.hash}/cover.png`), +})); + +vi.mock('@/libs/storage', () => ({ + downloadFile: vi.fn().mockResolvedValue(undefined), + uploadFile: vi.fn().mockResolvedValue('https://example.com/file'), + deleteFile: vi.fn(), + createProgressHandler: vi.fn().mockReturnValue(vi.fn()), + batchGetDownloadUrls: vi.fn().mockResolvedValue([]), +})); + +vi.mock('@/utils/file', () => ({ + ClosableFile: class {}, + RemoteFile: class { + async open() { + return new File(['content'], 'test.epub'); + } + }, +})); + +function createMockBook(overrides: Partial = {}): Book { + return { + hash: 'abc123', + format: 'EPUB' as BookFormat, + title: 'Test Book', + author: 'Author', + createdAt: Date.now(), + updatedAt: Date.now(), + deletedAt: null, + uploadedAt: null, + downloadedAt: Date.now(), + coverDownloadedAt: Date.now(), + ...overrides, + }; +} + +function createMockFs(): FileSystem { + return { + resolvePath: vi + .fn() + .mockReturnValue({ baseDir: 0, basePrefix: async () => '', fp: 'test', base: 'Books' }), + getURL: vi.fn().mockReturnValue('url'), + getBlobURL: vi.fn().mockResolvedValue('blob:url'), + getImageURL: vi.fn().mockResolvedValue('image:url'), + openFile: vi.fn().mockResolvedValue(new File(['content'], 'test.epub')), + copyFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue('content'), + writeFile: vi.fn().mockResolvedValue(undefined), + removeFile: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn().mockResolvedValue([]), + createDir: vi.fn().mockResolvedValue(undefined), + removeDir: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(true), + stats: vi.fn().mockResolvedValue({ + isFile: true, + isDirectory: false, + size: 100, + mtime: null, + atime: null, + birthtime: null, + }), + getPrefix: vi.fn().mockResolvedValue('Readest/Books'), + }; +} + +describe('cloudService', () => { + let mockFs: FileSystem; + + beforeEach(() => { + vi.clearAllMocks(); + mockFs = createMockFs(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('deleteBook', () => { + describe('local delete action', () => { + test('removes the local book file', async () => { + const book = createMockBook(); + await deleteBook(mockFs, book, 'local'); + + expect(mockFs.exists).toHaveBeenCalledWith(`${book.hash}/${book.title}.epub`, 'Books'); + expect(mockFs.removeFile).toHaveBeenCalledWith(`${book.hash}/${book.title}.epub`, 'Books'); + }); + + test('sets downloadedAt to null', async () => { + const book = createMockBook({ downloadedAt: 12345 }); + await deleteBook(mockFs, book, 'local'); + + expect(book.downloadedAt).toBeNull(); + }); + + test('does not set deletedAt for local-only delete', async () => { + const book = createMockBook({ deletedAt: null }); + await deleteBook(mockFs, book, 'local'); + + // local action does not modify deletedAt + expect(book.deletedAt).toBeNull(); + }); + + test('skips removal when file does not exist', async () => { + vi.mocked(mockFs.exists).mockResolvedValue(false); + const book = createMockBook(); + await deleteBook(mockFs, book, 'local'); + + expect(mockFs.removeFile).not.toHaveBeenCalled(); + }); + + test('only deletes book file, not cover (local action)', async () => { + const book = createMockBook(); + await deleteBook(mockFs, book, 'local'); + + // local action only deletes the book file + expect(mockFs.removeFile).toHaveBeenCalledTimes(1); + expect(mockFs.removeFile).toHaveBeenCalledWith(`${book.hash}/${book.title}.epub`, 'Books'); + }); + }); + + describe('both delete action', () => { + test('removes book file and cover', async () => { + const book = createMockBook({ uploadedAt: 1000 }); + await deleteBook(mockFs, book, 'both'); + + // 'both' deletes localBookFilename + coverFilename + expect(mockFs.removeFile).toHaveBeenCalledWith(`${book.hash}/${book.title}.epub`, 'Books'); + expect(mockFs.removeFile).toHaveBeenCalledWith(`${book.hash}/cover.png`, 'Books'); + }); + + test('sets deletedAt, clears downloadedAt and coverDownloadedAt', async () => { + const book = createMockBook({ + uploadedAt: 1000, + downloadedAt: 2000, + coverDownloadedAt: 3000, + }); + await deleteBook(mockFs, book, 'both'); + + expect(book.deletedAt).toBeGreaterThan(0); + expect(book.downloadedAt).toBeNull(); + expect(book.coverDownloadedAt).toBeNull(); + }); + + test('clears uploadedAt when uploaded', async () => { + const book = createMockBook({ uploadedAt: 1000 }); + await deleteBook(mockFs, book, 'both'); + + expect(book.uploadedAt).toBeNull(); + }); + }); + + describe('cloud delete action', () => { + test('does not delete local files', async () => { + const book = createMockBook({ uploadedAt: 1000 }); + await deleteBook(mockFs, book, 'cloud'); + + expect(mockFs.removeFile).not.toHaveBeenCalled(); + }); + + test('clears uploadedAt when previously uploaded', async () => { + const book = createMockBook({ uploadedAt: 1000 }); + await deleteBook(mockFs, book, 'cloud'); + + expect(book.uploadedAt).toBeNull(); + }); + + test('skips cloud delete when not uploaded', async () => { + const { deleteFile: deleteCloudFile } = await import('@/libs/storage'); + const book = createMockBook({ uploadedAt: null }); + await deleteBook(mockFs, book, 'cloud'); + + expect(deleteCloudFile).not.toHaveBeenCalled(); + }); + + test('calls deleteFile for remote book and cover', async () => { + const { deleteFile: deleteCloudFile } = await import('@/libs/storage'); + const book = createMockBook({ uploadedAt: 1000 }); + await deleteBook(mockFs, book, 'cloud'); + + expect(deleteCloudFile).toHaveBeenCalledTimes(2); + }); + + test('does not throw when cloud delete fails', async () => { + const { deleteFile: deleteCloudFile } = await import('@/libs/storage'); + vi.mocked(deleteCloudFile).mockImplementation(() => { + throw new Error('network error'); + }); + const book = createMockBook({ uploadedAt: 1000 }); + + // Should not throw + await deleteBook(mockFs, book, 'cloud'); + expect(book.uploadedAt).toBeNull(); + }); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts b/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts new file mode 100644 index 00000000..b58227b6 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/command-registry-extended.test.ts @@ -0,0 +1,481 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock react-icons before importing the module under test +vi.mock('react-icons/ri', () => ({ + RiFontSize: () => null, + RiDashboardLine: () => null, + RiTranslate: () => null, +})); +vi.mock('react-icons/vsc', () => ({ + VscSymbolColor: () => null, +})); +vi.mock('react-icons/lia', () => ({ + LiaHandPointerSolid: () => null, +})); +vi.mock('react-icons/io5', () => ({ + IoAccessibilityOutline: () => null, +})); +vi.mock('react-icons/pi', () => ({ + PiRobot: () => null, + PiSpeakerHigh: () => null, + PiSun: () => null, + PiMoon: () => null, +})); +vi.mock('react-icons/tb', () => ({ + TbSunMoon: () => null, +})); +vi.mock('react-icons/md', () => ({ + MdRefresh: () => null, +})); +vi.mock('react-icons', () => ({ + IconType: undefined, +})); + +vi.mock('@/utils/misc', () => ({ + stubTranslation: (key: string) => key, +})); + +import { + buildCommandRegistry, + searchCommands, + groupResultsByCategory, + getCategoryLabel, + getRecentCommands, + trackCommandUsage, + CommandItem, + CommandRegistryOptions, + CommandCategory, +} from '@/services/commandRegistry'; + +function createMockOptions( + overrides: Partial = {}, +): CommandRegistryOptions { + return { + _: (key: string) => key, + openSettingsPanel: vi.fn(), + toggleTheme: vi.fn(), + toggleFullscreen: vi.fn(), + toggleAlwaysOnTop: vi.fn(), + toggleScreenWakeLock: vi.fn(), + toggleAutoUpload: vi.fn(), + reloadPage: vi.fn(), + toggleOpenLastBooks: vi.fn(), + showAbout: vi.fn(), + toggleTelemetry: vi.fn(), + isDesktop: false, + ...overrides, + }; +} + +describe('buildCommandRegistry', () => { + it('should return an array of command items', () => { + const items = buildCommandRegistry(createMockOptions()); + expect(items.length).toBeGreaterThan(0); + }); + + it('should include settings items from all panels', () => { + const items = buildCommandRegistry(createMockOptions()); + const settingsItems = items.filter((i) => i.category === 'settings'); + expect(settingsItems.length).toBeGreaterThan(0); + + // Check that multiple panels are represented + const panels = new Set(settingsItems.map((i) => i.panel)); + expect(panels.has('Font')).toBe(true); + expect(panels.has('Layout')).toBe(true); + expect(panels.has('Color')).toBe(true); + expect(panels.has('Control')).toBe(true); + expect(panels.has('Language')).toBe(true); + expect(panels.has('Custom')).toBe(true); + }); + + it('should include action items', () => { + const items = buildCommandRegistry(createMockOptions()); + const actionItems = items.filter((i) => i.category === 'actions'); + expect(actionItems.length).toBeGreaterThan(0); + + const actionIds = actionItems.map((i) => i.id); + expect(actionIds).toContain('action.toggleTheme'); + expect(actionIds).toContain('action.fullscreen'); + expect(actionIds).toContain('action.reload'); + expect(actionIds).toContain('action.about'); + expect(actionIds).toContain('action.telemetry'); + }); + + it('should use the provided translation function for localized labels', () => { + const translate = (key: string) => `translated:${key}`; + const items = buildCommandRegistry(createMockOptions({ _: translate })); + const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize'); + expect(fontItem).toBeDefined(); + expect(fontItem!.localizedLabel).toBe('translated:Default Font Size'); + }); + + it('should set panelLabel using translation function', () => { + const translate = (key: string) => `t:${key}`; + const items = buildCommandRegistry(createMockOptions({ _: translate })); + + const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize'); + expect(fontItem!.panelLabel).toBe('t:Font'); + + // Control panel items use panelLabel 'Behavior' + const controlItem = items.find((i) => i.id === 'settings.control.scrolledMode'); + expect(controlItem!.panelLabel).toBe('t:Behavior'); + }); + + it('should call openSettingsPanel when settings item action is invoked', () => { + const openSettingsPanel = vi.fn(); + const items = buildCommandRegistry(createMockOptions({ openSettingsPanel })); + const fontItem = items.find((i) => i.id === 'settings.font.defaultFontSize')!; + fontItem.action(); + expect(openSettingsPanel).toHaveBeenCalledWith('Font', 'settings.font.defaultFontSize'); + }); + + it('should call correct action handler for action items', () => { + const toggleTheme = vi.fn(); + const reloadPage = vi.fn(); + const items = buildCommandRegistry(createMockOptions({ toggleTheme, reloadPage })); + + items.find((i) => i.id === 'action.toggleTheme')!.action(); + expect(toggleTheme).toHaveBeenCalled(); + + items.find((i) => i.id === 'action.reload')!.action(); + expect(reloadPage).toHaveBeenCalled(); + }); + + it('should set isAvailable for desktop-only actions', () => { + const items = buildCommandRegistry(createMockOptions({ isDesktop: false })); + + const fullscreen = items.find((i) => i.id === 'action.fullscreen')!; + expect(fullscreen.isAvailable).toBeDefined(); + expect(fullscreen.isAvailable!()).toBe(false); + + const alwaysOnTop = items.find((i) => i.id === 'action.alwaysOnTop')!; + expect(alwaysOnTop.isAvailable!()).toBe(false); + + const openLastBooks = items.find((i) => i.id === 'action.openLastBooks')!; + expect(openLastBooks.isAvailable!()).toBe(false); + }); + + it('should report desktop-only actions as available on desktop', () => { + const items = buildCommandRegistry(createMockOptions({ isDesktop: true })); + + const fullscreen = items.find((i) => i.id === 'action.fullscreen')!; + expect(fullscreen.isAvailable!()).toBe(true); + }); + + it('should not set isAvailable for non-desktop-only actions', () => { + const items = buildCommandRegistry(createMockOptions()); + const themeItem = items.find((i) => i.id === 'action.toggleTheme')!; + expect(themeItem.isAvailable).toBeUndefined(); + }); + + it('should have unique ids for all items', () => { + const items = buildCommandRegistry(createMockOptions()); + const ids = items.map((i) => i.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('should include AI panel items in non-production', () => { + const items = buildCommandRegistry(createMockOptions()); + const aiItems = items.filter((i) => i.panel === 'AI'); + // In test environment (not production), AI items should be included + expect(aiItems.length).toBeGreaterThan(0); + }); + + it('should give each settings item keywords and section', () => { + const items = buildCommandRegistry(createMockOptions()); + const settingsItems = items.filter((i) => i.category === 'settings'); + for (const item of settingsItems) { + expect(item.keywords.length).toBeGreaterThan(0); + } + }); +}); + +describe('searchCommands', () => { + let items: CommandItem[]; + + beforeEach(() => { + items = buildCommandRegistry(createMockOptions()); + }); + + it('should return empty array for empty query', () => { + expect(searchCommands('', items)).toEqual([]); + }); + + it('should return empty array for whitespace-only query', () => { + expect(searchCommands(' ', items)).toEqual([]); + }); + + it('should find items matching the query', () => { + const results = searchCommands('font', items); + expect(results.length).toBeGreaterThan(0); + // At least some results should be font-related + const hasFontResult = results.some( + (r) => r.item.id.includes('font') || r.item.labelKey.toLowerCase().includes('font'), + ); + expect(hasFontResult).toBe(true); + }); + + it('should filter out unavailable items', () => { + const itemsNonDesktop = buildCommandRegistry(createMockOptions({ isDesktop: false })); + const results = searchCommands('fullscreen', itemsNonDesktop); + // Fullscreen is desktop-only; should not appear when isDesktop is false + const hasFullscreen = results.some((r) => r.item.id === 'action.fullscreen'); + expect(hasFullscreen).toBe(false); + }); + + it('should include available items', () => { + const desktopItems = buildCommandRegistry(createMockOptions({ isDesktop: true })); + const results = searchCommands('fullscreen', desktopItems); + const hasFullscreen = results.some((r) => r.item.id === 'action.fullscreen'); + expect(hasFullscreen).toBe(true); + }); + + it('should return results with score and positions', () => { + const results = searchCommands('reload', items); + expect(results.length).toBeGreaterThan(0); + const first = results[0]!; + expect(typeof first.score).toBe('number'); + expect(first.positions).toBeDefined(); + expect(first.highlightIndices).toBeDefined(); + }); + + it('should search across keywords', () => { + const results = searchCommands('hyphen', items); + expect(results.length).toBeGreaterThan(0); + const hasHyphenation = results.some((r) => r.item.id === 'settings.layout.hyphenation'); + expect(hasHyphenation).toBe(true); + }); + + it('should search across panel label', () => { + const results = searchCommands('Behavior', items); + // Control panel items have panelLabel 'Behavior' + expect(results.length).toBeGreaterThan(0); + }); +}); + +describe('groupResultsByCategory', () => { + let items: CommandItem[]; + + beforeEach(() => { + items = buildCommandRegistry(createMockOptions({ isDesktop: true })); + }); + + it('should group results by category', () => { + const results = searchCommands('theme', items); + const grouped = groupResultsByCategory(results); + + expect(grouped).toHaveProperty('settings'); + expect(grouped).toHaveProperty('actions'); + expect(grouped).toHaveProperty('navigation'); + + // 'theme' should match both settings (Theme Mode under Color) and action (toggle theme) + expect(grouped.settings.length).toBeGreaterThan(0); + expect(grouped.actions.length).toBeGreaterThan(0); + }); + + it('should return empty arrays when no results', () => { + const grouped = groupResultsByCategory([]); + expect(grouped.settings).toEqual([]); + expect(grouped.actions).toEqual([]); + expect(grouped.navigation).toEqual([]); + }); + + it('should place all results into their correct category', () => { + const results = searchCommands('font', items); + const grouped = groupResultsByCategory(results); + + let totalGrouped = 0; + totalGrouped += grouped.settings.length; + totalGrouped += grouped.actions.length; + totalGrouped += grouped.navigation.length; + expect(totalGrouped).toBe(results.length); + }); +}); + +describe('getCategoryLabel', () => { + const translate = (key: string) => `t:${key}`; + + it('should return translated label for settings', () => { + expect(getCategoryLabel(translate, 'settings')).toBe('t:Settings'); + }); + + it('should return translated label for actions', () => { + expect(getCategoryLabel(translate, 'actions')).toBe('t:Actions'); + }); + + it('should return translated label for navigation', () => { + expect(getCategoryLabel(translate, 'navigation')).toBe('t:Navigation'); + }); + + it('should return the category string for unknown category', () => { + // Force an unknown category via type assertion + const unknown = 'unknown' as CommandCategory; + expect(getCategoryLabel(translate, unknown)).toBe('unknown'); + }); +}); + +describe('getRecentCommands', () => { + const originalLocalStorage = globalThis.localStorage; + + beforeEach(() => { + // Set up a fresh localStorage mock + const store: Record = {}; + const mockLocalStorage = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) { + delete store[key]; + } + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((_index: number) => null), + }; + Object.defineProperty(globalThis, 'localStorage', { + value: mockLocalStorage, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + writable: true, + configurable: true, + }); + }); + + it('should return empty array when no recent commands stored', () => { + const items = buildCommandRegistry(createMockOptions()); + expect(getRecentCommands(items)).toEqual([]); + }); + + it('should return matching recent commands', () => { + const items = buildCommandRegistry(createMockOptions()); + localStorage.setItem('recentCommands', JSON.stringify(['action.reload', 'action.about'])); + + const recent = getRecentCommands(items); + expect(recent).toHaveLength(2); + expect(recent[0]!.id).toBe('action.reload'); + expect(recent[1]!.id).toBe('action.about'); + }); + + it('should skip ids that do not exist in items', () => { + const items = buildCommandRegistry(createMockOptions()); + localStorage.setItem('recentCommands', JSON.stringify(['nonexistent.item', 'action.reload'])); + + const recent = getRecentCommands(items); + expect(recent).toHaveLength(1); + expect(recent[0]!.id).toBe('action.reload'); + }); + + it('should respect the limit parameter', () => { + const items = buildCommandRegistry(createMockOptions()); + localStorage.setItem( + 'recentCommands', + JSON.stringify(['action.reload', 'action.about', 'action.toggleTheme']), + ); + + const recent = getRecentCommands(items, 2); + expect(recent).toHaveLength(2); + }); + + it('should use default limit of 5', () => { + const items = buildCommandRegistry(createMockOptions()); + const ids = items.slice(0, 8).map((i) => i.id); + localStorage.setItem('recentCommands', JSON.stringify(ids)); + + const recent = getRecentCommands(items); + expect(recent).toHaveLength(5); + }); + + it('should return empty array on invalid JSON', () => { + const items = buildCommandRegistry(createMockOptions()); + localStorage.setItem('recentCommands', 'not-json'); + + const recent = getRecentCommands(items); + expect(recent).toEqual([]); + }); +}); + +describe('trackCommandUsage', () => { + const originalLocalStorage = globalThis.localStorage; + + beforeEach(() => { + const store: Record = {}; + const mockLocalStorage = { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete store[key]; + }), + clear: vi.fn(() => { + for (const key of Object.keys(store)) { + delete store[key]; + } + }), + get length() { + return Object.keys(store).length; + }, + key: vi.fn((_index: number) => null), + }; + Object.defineProperty(globalThis, 'localStorage', { + value: mockLocalStorage, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + value: originalLocalStorage, + writable: true, + configurable: true, + }); + }); + + it('should store a new command id', () => { + trackCommandUsage('action.reload'); + const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[]; + expect(stored).toContain('action.reload'); + }); + + it('should move existing command to front', () => { + localStorage.setItem('recentCommands', JSON.stringify(['action.about', 'action.reload'])); + + trackCommandUsage('action.reload'); + const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[]; + expect(stored[0]).toBe('action.reload'); + expect(stored[1]).toBe('action.about'); + // No duplicates + expect(stored.filter((id) => id === 'action.reload')).toHaveLength(1); + }); + + it('should limit stored commands to 10', () => { + const existingIds = Array.from({ length: 10 }, (_, i) => `cmd-${i}`); + localStorage.setItem('recentCommands', JSON.stringify(existingIds)); + + trackCommandUsage('new-cmd'); + const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[]; + expect(stored).toHaveLength(10); + expect(stored[0]).toBe('new-cmd'); + // Last item from the original list should have been dropped + expect(stored).not.toContain('cmd-9'); + }); + + it('should handle empty localStorage gracefully', () => { + trackCommandUsage('action.reload'); + const stored = JSON.parse(localStorage.getItem('recentCommands') ?? '[]') as string[]; + expect(stored).toEqual(['action.reload']); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/constants.test.ts b/apps/readest-app/src/__tests__/services/constants.test.ts new file mode 100644 index 00000000..462c2c8b --- /dev/null +++ b/apps/readest-app/src/__tests__/services/constants.test.ts @@ -0,0 +1,1104 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@/utils/config', () => ({ + getDefaultMaxBlockSize: vi.fn(() => 1600), + getDefaultMaxInlineSize: vi.fn(() => 720), +})); +vi.mock('@/utils/misc', () => ({ + stubTranslation: vi.fn((key: string) => key), + getOSPlatform: vi.fn(() => 'macos'), +})); + +import { + DATA_SUBDIR, + LOCAL_BOOKS_SUBDIR, + CLOUD_BOOKS_SUBDIR, + LOCAL_FONTS_SUBDIR, + LOCAL_IMAGES_SUBDIR, + SETTINGS_FILENAME, + SUPPORTED_BOOK_EXTS, + BOOK_ACCEPT_FORMATS, + BOOK_UNGROUPED_NAME, + BOOK_UNGROUPED_ID, + SUPPORTED_IMAGE_EXTS, + IMAGE_ACCEPT_FORMATS, + DEFAULT_KOSYNC_SETTINGS, + READWISE_API_BASE_URL, + DEFAULT_READWISE_SETTINGS, + DEFAULT_SYSTEM_SETTINGS, + DEFAULT_MOBILE_SYSTEM_SETTINGS, + HIGHLIGHT_COLOR_HEX, + READING_RULER_COLORS, + DEFAULT_READSETTINGS, + DEFAULT_MOBILE_READSETTINGS, + DEFAULT_BOOK_FONT, + DEFAULT_BOOK_LAYOUT, + DEFAULT_BOOK_LANGUAGE, + DEFAULT_BOOK_STYLE, + DEFAULT_MOBILE_VIEW_SETTINGS, + DEFAULT_CJK_VIEW_SETTINGS, + DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS, + DEFAULT_EINK_VIEW_SETTINGS, + DEFAULT_VIEW_CONFIG, + DEFAULT_TTS_CONFIG, + DEFAULT_TRANSLATOR_CONFIG, + DEFAULT_NOTE_EXPORT_CONFIG, + DEFAULT_ANNOTATOR_CONFIG, + DEFAULT_SCREEN_CONFIG, + DEFAULT_PARAGRAPH_MODE_CONFIG, + DEFAULT_BOOK_SEARCH_CONFIG, + SYSTEM_SETTINGS_VERSION, + SERIF_FONTS, + NON_FREE_FONTS, + CJK_SERIF_FONTS, + CJK_SANS_SERIF_FONTS, + SANS_SERIF_FONTS, + MONOSPACE_FONTS, + FALLBACK_FONTS, + WINDOWS_FONTS, + MACOS_FONTS, + LINUX_FONTS, + IOS_FONTS, + ANDROID_FONTS, + CJK_EXCLUDE_PATTENS, + CJK_FONTS_PATTENS, + BOOK_IDS_SEPARATOR, + DOWNLOAD_READEST_URL, + READEST_WEB_BASE_URL, + READEST_NODE_BASE_URL, + READEST_UPDATER_FILE, + READEST_CHANGELOG_FILE, + READEST_PUBLIC_STORAGE_BASE_URL, + READEST_OPDS_USER_AGENT, + SYNC_PROGRESS_INTERVAL_SEC, + SYNC_NOTES_INTERVAL_SEC, + SYNC_BOOKS_INTERVAL_SEC, + CHECK_UPDATE_INTERVAL_SEC, + MAX_ZOOM_LEVEL, + MIN_ZOOM_LEVEL, + ZOOM_STEP, + SHOW_UNREAD_STATUS_BADGE, + DEFAULT_STORAGE_QUOTA, + DEFAULT_DAILY_TRANSLATION_QUOTA, + DOUBLE_CLICK_INTERVAL_THRESHOLD_MS, + DISABLE_DOUBLE_CLICK_ON_MOBILE, + LONG_HOLD_THRESHOLD, + SIZE_PER_LOC, + SIZE_PER_TIME_UNIT, + CUSTOM_THEME_TEMPLATES, + MIGHT_BE_RTL_LANGS, + TRANSLATED_LANGS, + TRANSLATOR_LANGS, + SUPPORTED_LANGS, + SUPPORTED_LANGNAMES, +} from '@/services/constants'; + +describe('services/constants', () => { + // --------------------------------------------------------------------------- + // Directory & filename constants + // --------------------------------------------------------------------------- + describe('directory and filename constants', () => { + it('DATA_SUBDIR is a non-empty string', () => { + expect(typeof DATA_SUBDIR).toBe('string'); + expect(DATA_SUBDIR.length).toBeGreaterThan(0); + }); + + it('LOCAL_BOOKS_SUBDIR contains DATA_SUBDIR', () => { + expect(LOCAL_BOOKS_SUBDIR).toContain(DATA_SUBDIR); + }); + + it('CLOUD_BOOKS_SUBDIR contains DATA_SUBDIR', () => { + expect(CLOUD_BOOKS_SUBDIR).toContain(DATA_SUBDIR); + }); + + it('LOCAL_FONTS_SUBDIR contains DATA_SUBDIR', () => { + expect(LOCAL_FONTS_SUBDIR).toContain(DATA_SUBDIR); + }); + + it('LOCAL_IMAGES_SUBDIR contains DATA_SUBDIR', () => { + expect(LOCAL_IMAGES_SUBDIR).toContain(DATA_SUBDIR); + }); + + it('SETTINGS_FILENAME ends with .json', () => { + expect(SETTINGS_FILENAME).toMatch(/\.json$/); + }); + }); + + // --------------------------------------------------------------------------- + // Book formats + // --------------------------------------------------------------------------- + describe('book format constants', () => { + it('SUPPORTED_BOOK_EXTS is a non-empty array of strings', () => { + expect(Array.isArray(SUPPORTED_BOOK_EXTS)).toBe(true); + expect(SUPPORTED_BOOK_EXTS.length).toBeGreaterThan(0); + for (const ext of SUPPORTED_BOOK_EXTS) { + expect(typeof ext).toBe('string'); + } + }); + + it('SUPPORTED_BOOK_EXTS includes common formats', () => { + expect(SUPPORTED_BOOK_EXTS).toContain('epub'); + expect(SUPPORTED_BOOK_EXTS).toContain('pdf'); + expect(SUPPORTED_BOOK_EXTS).toContain('mobi'); + expect(SUPPORTED_BOOK_EXTS).toContain('txt'); + }); + + it('BOOK_ACCEPT_FORMATS is a comma-separated string of dotted extensions', () => { + expect(typeof BOOK_ACCEPT_FORMATS).toBe('string'); + expect(BOOK_ACCEPT_FORMATS).toContain('.epub'); + expect(BOOK_ACCEPT_FORMATS).toContain('.pdf'); + expect(BOOK_ACCEPT_FORMATS.split(', ').length).toBe(SUPPORTED_BOOK_EXTS.length); + }); + + it('BOOK_UNGROUPED_NAME and BOOK_UNGROUPED_ID are empty strings', () => { + expect(BOOK_UNGROUPED_NAME).toBe(''); + expect(BOOK_UNGROUPED_ID).toBe(''); + }); + }); + + // --------------------------------------------------------------------------- + // Image formats + // --------------------------------------------------------------------------- + describe('image format constants', () => { + it('SUPPORTED_IMAGE_EXTS is a non-empty array', () => { + expect(Array.isArray(SUPPORTED_IMAGE_EXTS)).toBe(true); + expect(SUPPORTED_IMAGE_EXTS.length).toBeGreaterThan(0); + expect(SUPPORTED_IMAGE_EXTS).toContain('png'); + expect(SUPPORTED_IMAGE_EXTS).toContain('jpg'); + }); + + it('IMAGE_ACCEPT_FORMATS corresponds to SUPPORTED_IMAGE_EXTS', () => { + expect(typeof IMAGE_ACCEPT_FORMATS).toBe('string'); + expect(IMAGE_ACCEPT_FORMATS.split(', ').length).toBe(SUPPORTED_IMAGE_EXTS.length); + }); + }); + + // --------------------------------------------------------------------------- + // KOSync settings + // --------------------------------------------------------------------------- + describe('DEFAULT_KOSYNC_SETTINGS', () => { + it('is an object with required properties', () => { + expect(typeof DEFAULT_KOSYNC_SETTINGS).toBe('object'); + expect(DEFAULT_KOSYNC_SETTINGS).not.toBeNull(); + }); + + it('has expected keys with correct types', () => { + expect(typeof DEFAULT_KOSYNC_SETTINGS.serverUrl).toBe('string'); + expect(DEFAULT_KOSYNC_SETTINGS.serverUrl).toMatch(/^https?:\/\//); + expect(typeof DEFAULT_KOSYNC_SETTINGS.username).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.userkey).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.deviceId).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.deviceName).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.checksumMethod).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.strategy).toBe('string'); + expect(typeof DEFAULT_KOSYNC_SETTINGS.enabled).toBe('boolean'); + expect(DEFAULT_KOSYNC_SETTINGS.enabled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Readwise settings + // --------------------------------------------------------------------------- + describe('Readwise constants', () => { + it('READWISE_API_BASE_URL is a valid URL string', () => { + expect(typeof READWISE_API_BASE_URL).toBe('string'); + expect(READWISE_API_BASE_URL).toMatch(/^https:\/\//); + }); + + it('DEFAULT_READWISE_SETTINGS has expected structure', () => { + expect(typeof DEFAULT_READWISE_SETTINGS).toBe('object'); + expect(DEFAULT_READWISE_SETTINGS.enabled).toBe(false); + expect(typeof DEFAULT_READWISE_SETTINGS.accessToken).toBe('string'); + expect(typeof DEFAULT_READWISE_SETTINGS.lastSyncedAt).toBe('number'); + }); + }); + + // --------------------------------------------------------------------------- + // System settings + // --------------------------------------------------------------------------- + describe('DEFAULT_SYSTEM_SETTINGS', () => { + it('is a non-null object', () => { + expect(typeof DEFAULT_SYSTEM_SETTINGS).toBe('object'); + expect(DEFAULT_SYSTEM_SETTINGS).not.toBeNull(); + }); + + it('has boolean flags', () => { + expect(typeof DEFAULT_SYSTEM_SETTINGS.keepLogin).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.autoUpload).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysOnTop).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.openBookInNewWindow).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysShowStatusBar).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.alwaysInForeground).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.autoCheckUpdates).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.screenWakeLock).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.openLastBooks).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.autoImportBooksOnOpen).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.telemetryEnabled).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.discordRichPresenceEnabled).toBe('boolean'); + }); + + it('has screen brightness in valid range', () => { + expect(typeof DEFAULT_SYSTEM_SETTINGS.screenBrightness).toBe('number'); + expect(DEFAULT_SYSTEM_SETTINGS.screenBrightness!).toBeGreaterThanOrEqual(-1); + expect(DEFAULT_SYSTEM_SETTINGS.screenBrightness!).toBeLessThanOrEqual(100); + }); + + it('has library settings', () => { + expect(DEFAULT_SYSTEM_SETTINGS.libraryViewMode).toBe('grid'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.librarySortBy).toBe('string'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.librarySortAscending).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.libraryGroupBy).toBe('string'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.libraryCoverFit).toBe('string'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.libraryAutoColumns).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.libraryColumns).toBe('number'); + }); + + it('has metadata collapse settings', () => { + expect(typeof DEFAULT_SYSTEM_SETTINGS.metadataSeriesCollapsed).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.metadataOthersCollapsed).toBe('boolean'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.metadataDescriptionCollapsed).toBe('boolean'); + }); + + it('has nested settings objects', () => { + expect(DEFAULT_SYSTEM_SETTINGS.kosync).toBeDefined(); + expect(DEFAULT_SYSTEM_SETTINGS.readwise).toBeDefined(); + expect(DEFAULT_SYSTEM_SETTINGS.aiSettings).toBeDefined(); + }); + + it('has sync timestamps', () => { + expect(typeof DEFAULT_SYSTEM_SETTINGS.lastSyncedAtBooks).toBe('number'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.lastSyncedAtConfigs).toBe('number'); + expect(typeof DEFAULT_SYSTEM_SETTINGS.lastSyncedAtNotes).toBe('number'); + }); + + it('lastOpenBooks is an empty array', () => { + expect(Array.isArray(DEFAULT_SYSTEM_SETTINGS.lastOpenBooks)).toBe(true); + expect(DEFAULT_SYSTEM_SETTINGS.lastOpenBooks!.length).toBe(0); + }); + }); + + describe('DEFAULT_MOBILE_SYSTEM_SETTINGS', () => { + it('is an object with libraryColumns', () => { + expect(typeof DEFAULT_MOBILE_SYSTEM_SETTINGS).toBe('object'); + expect(typeof DEFAULT_MOBILE_SYSTEM_SETTINGS.libraryColumns).toBe('number'); + expect(DEFAULT_MOBILE_SYSTEM_SETTINGS.libraryColumns).toBeGreaterThan(0); + }); + }); + + // --------------------------------------------------------------------------- + // Highlight colors + // --------------------------------------------------------------------------- + describe('HIGHLIGHT_COLOR_HEX', () => { + it('is a record with expected color keys', () => { + expect(typeof HIGHLIGHT_COLOR_HEX).toBe('object'); + const keys = Object.keys(HIGHLIGHT_COLOR_HEX); + expect(keys).toContain('red'); + expect(keys).toContain('yellow'); + expect(keys).toContain('green'); + expect(keys).toContain('blue'); + expect(keys).toContain('violet'); + }); + + it('all values are hex color strings', () => { + for (const value of Object.values(HIGHLIGHT_COLOR_HEX)) { + expect(value).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); + }); + + describe('READING_RULER_COLORS', () => { + it('has expected keys', () => { + const keys = Object.keys(READING_RULER_COLORS); + expect(keys).toContain('transparent'); + expect(keys).toContain('yellow'); + expect(keys).toContain('green'); + expect(keys).toContain('blue'); + expect(keys).toContain('rose'); + }); + + it('all values are hex color strings', () => { + for (const value of Object.values(READING_RULER_COLORS)) { + expect(value).toMatch(/^#[0-9a-fA-F]{6,8}$/); + } + }); + }); + + // --------------------------------------------------------------------------- + // Read settings + // --------------------------------------------------------------------------- + describe('DEFAULT_READSETTINGS', () => { + it('is a non-null object', () => { + expect(typeof DEFAULT_READSETTINGS).toBe('object'); + expect(DEFAULT_READSETTINGS).not.toBeNull(); + }); + + it('has sidebar and notebook settings', () => { + expect(typeof DEFAULT_READSETTINGS.sideBarWidth).toBe('string'); + expect(typeof DEFAULT_READSETTINGS.isSideBarPinned).toBe('boolean'); + expect(typeof DEFAULT_READSETTINGS.notebookWidth).toBe('string'); + expect(typeof DEFAULT_READSETTINGS.isNotebookPinned).toBe('boolean'); + expect(typeof DEFAULT_READSETTINGS.notebookActiveTab).toBe('string'); + }); + + it('has cursor and translation settings', () => { + expect(typeof DEFAULT_READSETTINGS.autohideCursor).toBe('boolean'); + expect(typeof DEFAULT_READSETTINGS.translationProvider).toBe('string'); + expect(typeof DEFAULT_READSETTINGS.translateTargetLang).toBe('string'); + }); + + it('has highlight settings', () => { + expect(typeof DEFAULT_READSETTINGS.highlightStyle).toBe('string'); + expect(typeof DEFAULT_READSETTINGS.highlightStyles).toBe('object'); + expect(typeof DEFAULT_READSETTINGS.customHighlightColors).toBe('object'); + expect(Array.isArray(DEFAULT_READSETTINGS.userHighlightColors)).toBe(true); + expect(Array.isArray(DEFAULT_READSETTINGS.customTtsHighlightColors)).toBe(true); + }); + + it('has custom themes as an array', () => { + expect(Array.isArray(DEFAULT_READSETTINGS.customThemes)).toBe(true); + }); + }); + + describe('DEFAULT_MOBILE_READSETTINGS', () => { + it('is a partial object with expected overrides', () => { + expect(typeof DEFAULT_MOBILE_READSETTINGS).toBe('object'); + expect(typeof DEFAULT_MOBILE_READSETTINGS.sideBarWidth).toBe('string'); + expect(DEFAULT_MOBILE_READSETTINGS.isSideBarPinned).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Book font + // --------------------------------------------------------------------------- + describe('DEFAULT_BOOK_FONT', () => { + it('has all font properties', () => { + expect(typeof DEFAULT_BOOK_FONT.serifFont).toBe('string'); + expect(typeof DEFAULT_BOOK_FONT.sansSerifFont).toBe('string'); + expect(typeof DEFAULT_BOOK_FONT.monospaceFont).toBe('string'); + expect(typeof DEFAULT_BOOK_FONT.defaultFont).toBe('string'); + expect(typeof DEFAULT_BOOK_FONT.defaultCJKFont).toBe('string'); + }); + + it('has reasonable font size defaults', () => { + expect(DEFAULT_BOOK_FONT.defaultFontSize).toBeGreaterThanOrEqual(8); + expect(DEFAULT_BOOK_FONT.defaultFontSize).toBeLessThanOrEqual(72); + expect(DEFAULT_BOOK_FONT.minimumFontSize).toBeGreaterThanOrEqual(1); + expect(DEFAULT_BOOK_FONT.minimumFontSize).toBeLessThanOrEqual( + DEFAULT_BOOK_FONT.defaultFontSize, + ); + }); + + it('has a valid font weight', () => { + expect(DEFAULT_BOOK_FONT.fontWeight).toBeGreaterThanOrEqual(100); + expect(DEFAULT_BOOK_FONT.fontWeight).toBeLessThanOrEqual(900); + }); + }); + + // --------------------------------------------------------------------------- + // Book layout + // --------------------------------------------------------------------------- + describe('DEFAULT_BOOK_LAYOUT', () => { + it('has margin properties as positive numbers', () => { + expect(DEFAULT_BOOK_LAYOUT.marginTopPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.marginBottomPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.marginLeftPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.marginRightPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.compactMarginTopPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.compactMarginBottomPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.compactMarginLeftPx).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.compactMarginRightPx).toBeGreaterThanOrEqual(0); + }); + + it('has gap percent in a reasonable range', () => { + expect(DEFAULT_BOOK_LAYOUT.gapPercent).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_LAYOUT.gapPercent).toBeLessThanOrEqual(100); + }); + + it('has boolean layout flags', () => { + expect(typeof DEFAULT_BOOK_LAYOUT.scrolled).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.noContinuousScroll).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.disableClick).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.fullscreenClickArea).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.swapClickArea).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.disableDoubleClick).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.volumeKeysToFlip).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.vertical).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.rtl).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.allowScript).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LAYOUT.hideScrollbar).toBe('boolean'); + }); + + it('has maxColumnCount as a positive number', () => { + expect(DEFAULT_BOOK_LAYOUT.maxColumnCount).toBeGreaterThanOrEqual(1); + }); + + it('has maxInlineSize and maxBlockSize from mocked config', () => { + expect(DEFAULT_BOOK_LAYOUT.maxInlineSize).toBe(720); + expect(DEFAULT_BOOK_LAYOUT.maxBlockSize).toBe(1600); + }); + + it('has writingMode as a string', () => { + expect(typeof DEFAULT_BOOK_LAYOUT.writingMode).toBe('string'); + }); + + it('has scrollingOverlap as a number', () => { + expect(typeof DEFAULT_BOOK_LAYOUT.scrollingOverlap).toBe('number'); + }); + }); + + // --------------------------------------------------------------------------- + // Book language + // --------------------------------------------------------------------------- + describe('DEFAULT_BOOK_LANGUAGE', () => { + it('has expected properties', () => { + expect(typeof DEFAULT_BOOK_LANGUAGE.replaceQuotationMarks).toBe('boolean'); + expect(typeof DEFAULT_BOOK_LANGUAGE.convertChineseVariant).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // Book style + // --------------------------------------------------------------------------- + describe('DEFAULT_BOOK_STYLE', () => { + it('is a non-null object', () => { + expect(typeof DEFAULT_BOOK_STYLE).toBe('object'); + expect(DEFAULT_BOOK_STYLE).not.toBeNull(); + }); + + it('has zoom level in valid range', () => { + expect(DEFAULT_BOOK_STYLE.zoomLevel).toBeGreaterThanOrEqual(MIN_ZOOM_LEVEL); + expect(DEFAULT_BOOK_STYLE.zoomLevel).toBeLessThanOrEqual(MAX_ZOOM_LEVEL); + }); + + it('has typography settings', () => { + expect(typeof DEFAULT_BOOK_STYLE.paragraphMargin).toBe('number'); + expect(DEFAULT_BOOK_STYLE.lineHeight).toBeGreaterThan(0); + expect(typeof DEFAULT_BOOK_STYLE.wordSpacing).toBe('number'); + expect(typeof DEFAULT_BOOK_STYLE.letterSpacing).toBe('number'); + expect(typeof DEFAULT_BOOK_STYLE.textIndent).toBe('number'); + }); + + it('has boolean style flags', () => { + expect(typeof DEFAULT_BOOK_STYLE.fullJustification).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.hyphenation).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.invertImgColorInDark).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.overrideFont).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.overrideLayout).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.overrideColor).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.codeHighlighting).toBe('boolean'); + expect(typeof DEFAULT_BOOK_STYLE.keepCoverSpread).toBe('boolean'); + }); + + it('has theme and appearance settings', () => { + expect(typeof DEFAULT_BOOK_STYLE.theme).toBe('string'); + expect(typeof DEFAULT_BOOK_STYLE.backgroundTextureId).toBe('string'); + expect(typeof DEFAULT_BOOK_STYLE.backgroundOpacity).toBe('number'); + expect(DEFAULT_BOOK_STYLE.backgroundOpacity).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_STYLE.backgroundOpacity).toBeLessThanOrEqual(1); + expect(typeof DEFAULT_BOOK_STYLE.backgroundSize).toBe('string'); + expect(typeof DEFAULT_BOOK_STYLE.highlightOpacity).toBe('number'); + expect(DEFAULT_BOOK_STYLE.highlightOpacity).toBeGreaterThanOrEqual(0); + expect(DEFAULT_BOOK_STYLE.highlightOpacity).toBeLessThanOrEqual(1); + }); + + it('has code language setting', () => { + expect(typeof DEFAULT_BOOK_STYLE.codeLanguage).toBe('string'); + }); + + it('has user stylesheet strings', () => { + expect(typeof DEFAULT_BOOK_STYLE.userStylesheet).toBe('string'); + expect(typeof DEFAULT_BOOK_STYLE.userUIStylesheet).toBe('string'); + }); + + it('has PDF-specific settings', () => { + expect(typeof DEFAULT_BOOK_STYLE.zoomMode).toBe('string'); + expect(typeof DEFAULT_BOOK_STYLE.spreadMode).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // View settings overrides (mobile, CJK, fixed layout, eink) + // --------------------------------------------------------------------------- + describe('view settings overrides', () => { + it('DEFAULT_MOBILE_VIEW_SETTINGS has expected overrides', () => { + expect(typeof DEFAULT_MOBILE_VIEW_SETTINGS).toBe('object'); + expect(DEFAULT_MOBILE_VIEW_SETTINGS.fullJustification).toBe(false); + expect(DEFAULT_MOBILE_VIEW_SETTINGS.animated).toBe(true); + expect(typeof DEFAULT_MOBILE_VIEW_SETTINGS.defaultFont).toBe('string'); + expect(typeof DEFAULT_MOBILE_VIEW_SETTINGS.marginBottomPx).toBe('number'); + expect(DEFAULT_MOBILE_VIEW_SETTINGS.disableDoubleClick).toBe(true); + expect(typeof DEFAULT_MOBILE_VIEW_SETTINGS.spreadMode).toBe('string'); + }); + + it('DEFAULT_CJK_VIEW_SETTINGS has CJK typography overrides', () => { + expect(typeof DEFAULT_CJK_VIEW_SETTINGS).toBe('object'); + expect(DEFAULT_CJK_VIEW_SETTINGS.fullJustification).toBe(true); + expect(typeof DEFAULT_CJK_VIEW_SETTINGS.textIndent).toBe('number'); + expect(typeof DEFAULT_CJK_VIEW_SETTINGS.paragraphMargin).toBe('number'); + expect(typeof DEFAULT_CJK_VIEW_SETTINGS.lineHeight).toBe('number'); + expect(DEFAULT_CJK_VIEW_SETTINGS.lineHeight!).toBeGreaterThan(0); + }); + + it('DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS has overrideColor set', () => { + expect(typeof DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS).toBe('object'); + expect(DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS.overrideColor).toBe(false); + }); + + it('DEFAULT_EINK_VIEW_SETTINGS has eink properties', () => { + expect(typeof DEFAULT_EINK_VIEW_SETTINGS).toBe('object'); + expect(DEFAULT_EINK_VIEW_SETTINGS.isEink).toBe(true); + expect(DEFAULT_EINK_VIEW_SETTINGS.animated).toBe(false); + expect(DEFAULT_EINK_VIEW_SETTINGS.volumeKeysToFlip).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // View config + // --------------------------------------------------------------------------- + describe('DEFAULT_VIEW_CONFIG', () => { + it('is a non-null object', () => { + expect(typeof DEFAULT_VIEW_CONFIG).toBe('object'); + expect(DEFAULT_VIEW_CONFIG).not.toBeNull(); + }); + + it('has sidebar tab as a string', () => { + expect(typeof DEFAULT_VIEW_CONFIG.sideBarTab).toBe('string'); + }); + + it('has boolean display flags', () => { + expect(typeof DEFAULT_VIEW_CONFIG.showHeader).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showFooter).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showBarsOnScroll).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showRemainingTime).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showRemainingPages).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showProgressInfo).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showCurrentTime).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showCurrentBatteryStatus).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showBatteryPercentage).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.use24HourClock).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.tapToToggleFooter).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showMarginsOnScroll).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.showPaginationButtons).toBe('boolean'); + }); + + it('has progress style settings', () => { + expect(typeof DEFAULT_VIEW_CONFIG.progressStyle).toBe('string'); + expect(typeof DEFAULT_VIEW_CONFIG.progressInfoMode).toBe('string'); + }); + + it('has animation and eink flags', () => { + expect(typeof DEFAULT_VIEW_CONFIG.animated).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.isEink).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.isColorEink).toBe('boolean'); + }); + + it('has reading ruler settings', () => { + expect(typeof DEFAULT_VIEW_CONFIG.readingRulerEnabled).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.readingRulerLines).toBe('number'); + expect(DEFAULT_VIEW_CONFIG.readingRulerLines).toBeGreaterThan(0); + expect(typeof DEFAULT_VIEW_CONFIG.readingRulerPosition).toBe('number'); + expect(typeof DEFAULT_VIEW_CONFIG.readingRulerOpacity).toBe('number'); + expect(DEFAULT_VIEW_CONFIG.readingRulerOpacity).toBeGreaterThanOrEqual(0); + expect(DEFAULT_VIEW_CONFIG.readingRulerOpacity).toBeLessThanOrEqual(1); + expect(typeof DEFAULT_VIEW_CONFIG.readingRulerColor).toBe('string'); + }); + + it('has border settings', () => { + expect(typeof DEFAULT_VIEW_CONFIG.doubleBorder).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.borderColor).toBe('string'); + }); + + it('has sorted TOC and UI language', () => { + expect(typeof DEFAULT_VIEW_CONFIG.sortedTOC).toBe('boolean'); + expect(typeof DEFAULT_VIEW_CONFIG.uiLanguage).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // TTS config + // --------------------------------------------------------------------------- + describe('DEFAULT_TTS_CONFIG', () => { + it('has expected properties', () => { + expect(typeof DEFAULT_TTS_CONFIG).toBe('object'); + expect(typeof DEFAULT_TTS_CONFIG.ttsRate).toBe('number'); + expect(DEFAULT_TTS_CONFIG.ttsRate).toBeGreaterThan(0); + expect(typeof DEFAULT_TTS_CONFIG.ttsVoice).toBe('string'); + expect(typeof DEFAULT_TTS_CONFIG.ttsLocation).toBe('string'); + expect(typeof DEFAULT_TTS_CONFIG.showTTSBar).toBe('boolean'); + expect(typeof DEFAULT_TTS_CONFIG.ttsMediaMetadata).toBe('string'); + }); + + it('has ttsHighlightOptions with style and color', () => { + expect(typeof DEFAULT_TTS_CONFIG.ttsHighlightOptions).toBe('object'); + expect(typeof DEFAULT_TTS_CONFIG.ttsHighlightOptions.style).toBe('string'); + expect(typeof DEFAULT_TTS_CONFIG.ttsHighlightOptions.color).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // Translator config + // --------------------------------------------------------------------------- + describe('DEFAULT_TRANSLATOR_CONFIG', () => { + it('has expected properties', () => { + expect(typeof DEFAULT_TRANSLATOR_CONFIG).toBe('object'); + expect(typeof DEFAULT_TRANSLATOR_CONFIG.translationEnabled).toBe('boolean'); + expect(typeof DEFAULT_TRANSLATOR_CONFIG.translationProvider).toBe('string'); + expect(typeof DEFAULT_TRANSLATOR_CONFIG.translateTargetLang).toBe('string'); + expect(typeof DEFAULT_TRANSLATOR_CONFIG.showTranslateSource).toBe('boolean'); + expect(typeof DEFAULT_TRANSLATOR_CONFIG.ttsReadAloudText).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // Note export config + // --------------------------------------------------------------------------- + describe('DEFAULT_NOTE_EXPORT_CONFIG', () => { + it('has all boolean include flags', () => { + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeTitle).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeAuthor).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeDate).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeChapterTitles).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeQuotes).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeNotes).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includePageNumber).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeTimestamp).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.includeChapterSeparator).toBe('boolean'); + }); + + it('has separator and template settings', () => { + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.noteSeparator).toBe('string'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.useCustomTemplate).toBe('boolean'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.customTemplate).toBe('string'); + expect(typeof DEFAULT_NOTE_EXPORT_CONFIG.exportAsPlainText).toBe('boolean'); + }); + }); + + // --------------------------------------------------------------------------- + // Annotator config + // --------------------------------------------------------------------------- + describe('DEFAULT_ANNOTATOR_CONFIG', () => { + it('has expected structure', () => { + expect(typeof DEFAULT_ANNOTATOR_CONFIG).toBe('object'); + expect(typeof DEFAULT_ANNOTATOR_CONFIG.enableAnnotationQuickActions).toBe('boolean'); + expect(DEFAULT_ANNOTATOR_CONFIG.annotationQuickAction).toBeNull(); + expect(typeof DEFAULT_ANNOTATOR_CONFIG.copyToNotebook).toBe('boolean'); + expect(DEFAULT_ANNOTATOR_CONFIG.noteExportConfig).toBeDefined(); + expect(DEFAULT_ANNOTATOR_CONFIG.noteExportConfig).toBe(DEFAULT_NOTE_EXPORT_CONFIG); + }); + }); + + // --------------------------------------------------------------------------- + // Screen config + // --------------------------------------------------------------------------- + describe('DEFAULT_SCREEN_CONFIG', () => { + it('has screen orientation', () => { + expect(typeof DEFAULT_SCREEN_CONFIG).toBe('object'); + expect(typeof DEFAULT_SCREEN_CONFIG.screenOrientation).toBe('string'); + }); + }); + + // --------------------------------------------------------------------------- + // Paragraph mode config + // --------------------------------------------------------------------------- + describe('DEFAULT_PARAGRAPH_MODE_CONFIG', () => { + it('has enabled flag set to false', () => { + expect(typeof DEFAULT_PARAGRAPH_MODE_CONFIG).toBe('object'); + expect(DEFAULT_PARAGRAPH_MODE_CONFIG.enabled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Book search config + // --------------------------------------------------------------------------- + describe('DEFAULT_BOOK_SEARCH_CONFIG', () => { + it('has expected search options', () => { + expect(typeof DEFAULT_BOOK_SEARCH_CONFIG).toBe('object'); + expect(typeof DEFAULT_BOOK_SEARCH_CONFIG.scope).toBe('string'); + expect(typeof DEFAULT_BOOK_SEARCH_CONFIG.matchCase).toBe('boolean'); + expect(typeof DEFAULT_BOOK_SEARCH_CONFIG.matchWholeWords).toBe('boolean'); + expect(typeof DEFAULT_BOOK_SEARCH_CONFIG.matchDiacritics).toBe('boolean'); + }); + }); + + // --------------------------------------------------------------------------- + // System settings version + // --------------------------------------------------------------------------- + describe('SYSTEM_SETTINGS_VERSION', () => { + it('is a positive integer', () => { + expect(typeof SYSTEM_SETTINGS_VERSION).toBe('number'); + expect(Number.isInteger(SYSTEM_SETTINGS_VERSION)).toBe(true); + expect(SYSTEM_SETTINGS_VERSION).toBeGreaterThanOrEqual(1); + }); + }); + + // --------------------------------------------------------------------------- + // Font arrays + // --------------------------------------------------------------------------- + describe('font arrays', () => { + const fontArrays = [ + { name: 'SERIF_FONTS', value: SERIF_FONTS }, + { name: 'SANS_SERIF_FONTS', value: SANS_SERIF_FONTS }, + { name: 'MONOSPACE_FONTS', value: MONOSPACE_FONTS }, + { name: 'CJK_SERIF_FONTS', value: CJK_SERIF_FONTS }, + { name: 'CJK_SANS_SERIF_FONTS', value: CJK_SANS_SERIF_FONTS }, + { name: 'FALLBACK_FONTS', value: FALLBACK_FONTS }, + { name: 'NON_FREE_FONTS', value: NON_FREE_FONTS }, + ]; + + for (const { name, value } of fontArrays) { + it(`${name} is a non-empty array of strings`, () => { + expect(Array.isArray(value)).toBe(true); + expect(value.length).toBeGreaterThan(0); + for (const font of value) { + expect(typeof font).toBe('string'); + expect(font.length).toBeGreaterThan(0); + } + }); + } + + it('NON_FREE_FONTS is a subset of SERIF_FONTS', () => { + for (const font of NON_FREE_FONTS) { + expect(SERIF_FONTS).toContain(font); + } + }); + }); + + // --------------------------------------------------------------------------- + // Platform font arrays + // --------------------------------------------------------------------------- + describe('platform font arrays', () => { + const platformFontArrays = [ + { name: 'WINDOWS_FONTS', value: WINDOWS_FONTS }, + { name: 'MACOS_FONTS', value: MACOS_FONTS }, + { name: 'LINUX_FONTS', value: LINUX_FONTS }, + { name: 'IOS_FONTS', value: IOS_FONTS }, + { name: 'ANDROID_FONTS', value: ANDROID_FONTS }, + ]; + + for (const { name, value } of platformFontArrays) { + it(`${name} is a non-empty array of strings`, () => { + expect(Array.isArray(value)).toBe(true); + expect(value.length).toBeGreaterThan(0); + for (const font of value) { + expect(typeof font).toBe('string'); + expect(font.length).toBeGreaterThan(0); + } + }); + } + + it('WINDOWS_FONTS has many entries', () => { + expect(WINDOWS_FONTS.length).toBeGreaterThan(30); + }); + + it('MACOS_FONTS has many entries', () => { + expect(MACOS_FONTS.length).toBeGreaterThan(30); + }); + + it('LINUX_FONTS has many entries', () => { + expect(LINUX_FONTS.length).toBeGreaterThan(20); + }); + }); + + // --------------------------------------------------------------------------- + // CJK regex patterns + // --------------------------------------------------------------------------- + describe('CJK regex patterns', () => { + it('CJK_EXCLUDE_PATTENS is a RegExp', () => { + expect(CJK_EXCLUDE_PATTENS).toBeInstanceOf(RegExp); + }); + + it('CJK_EXCLUDE_PATTENS matches known exclude terms', () => { + expect(CJK_EXCLUDE_PATTENS.test('AlBayan')).toBe(true); + expect(CJK_EXCLUDE_PATTENS.test('STIX')).toBe(true); + expect(CJK_EXCLUDE_PATTENS.test('Myanmar')).toBe(true); + }); + + it('CJK_EXCLUDE_PATTENS is case-insensitive', () => { + expect(CJK_EXCLUDE_PATTENS.flags).toContain('i'); + }); + + it('CJK_FONTS_PATTENS is a RegExp', () => { + expect(CJK_FONTS_PATTENS).toBeInstanceOf(RegExp); + }); + + it('CJK_FONTS_PATTENS matches CJK font names', () => { + expect(CJK_FONTS_PATTENS.test('Noto Sans CJK SC')).toBe(true); + expect(CJK_FONTS_PATTENS.test('PingFang SC')).toBe(true); + expect(CJK_FONTS_PATTENS.test('Hiragino Sans')).toBe(true); + expect(CJK_FONTS_PATTENS.test('Source Han Sans')).toBe(true); + }); + + it('CJK_FONTS_PATTENS is case-insensitive', () => { + expect(CJK_FONTS_PATTENS.flags).toContain('i'); + }); + }); + + // --------------------------------------------------------------------------- + // URL constants + // --------------------------------------------------------------------------- + describe('URL constants', () => { + it('BOOK_IDS_SEPARATOR is a single character', () => { + expect(typeof BOOK_IDS_SEPARATOR).toBe('string'); + expect(BOOK_IDS_SEPARATOR.length).toBe(1); + }); + + it('DOWNLOAD_READEST_URL is a valid URL', () => { + expect(DOWNLOAD_READEST_URL).toMatch(/^https:\/\//); + }); + + it('READEST_WEB_BASE_URL is a valid URL', () => { + expect(READEST_WEB_BASE_URL).toMatch(/^https:\/\//); + }); + + it('READEST_NODE_BASE_URL is a valid URL', () => { + expect(READEST_NODE_BASE_URL).toMatch(/^https:\/\//); + }); + + it('READEST_UPDATER_FILE is a URL ending with .json', () => { + expect(READEST_UPDATER_FILE).toMatch(/^https:\/\//); + expect(READEST_UPDATER_FILE).toMatch(/\.json$/); + }); + + it('READEST_CHANGELOG_FILE is a URL ending with .json', () => { + expect(READEST_CHANGELOG_FILE).toMatch(/^https:\/\//); + expect(READEST_CHANGELOG_FILE).toMatch(/\.json$/); + }); + + it('READEST_PUBLIC_STORAGE_BASE_URL is a valid URL', () => { + expect(READEST_PUBLIC_STORAGE_BASE_URL).toMatch(/^https:\/\//); + }); + + it('READEST_OPDS_USER_AGENT is a non-empty string', () => { + expect(typeof READEST_OPDS_USER_AGENT).toBe('string'); + expect(READEST_OPDS_USER_AGENT.length).toBeGreaterThan(0); + }); + }); + + // --------------------------------------------------------------------------- + // Sync interval constants + // --------------------------------------------------------------------------- + describe('sync interval constants', () => { + it('SYNC_PROGRESS_INTERVAL_SEC is a positive number', () => { + expect(typeof SYNC_PROGRESS_INTERVAL_SEC).toBe('number'); + expect(SYNC_PROGRESS_INTERVAL_SEC).toBeGreaterThan(0); + }); + + it('SYNC_NOTES_INTERVAL_SEC is a positive number', () => { + expect(typeof SYNC_NOTES_INTERVAL_SEC).toBe('number'); + expect(SYNC_NOTES_INTERVAL_SEC).toBeGreaterThan(0); + }); + + it('SYNC_BOOKS_INTERVAL_SEC is a positive number', () => { + expect(typeof SYNC_BOOKS_INTERVAL_SEC).toBe('number'); + expect(SYNC_BOOKS_INTERVAL_SEC).toBeGreaterThan(0); + }); + + it('CHECK_UPDATE_INTERVAL_SEC is at least one hour', () => { + expect(typeof CHECK_UPDATE_INTERVAL_SEC).toBe('number'); + expect(CHECK_UPDATE_INTERVAL_SEC).toBeGreaterThanOrEqual(3600); + }); + }); + + // --------------------------------------------------------------------------- + // Zoom constants + // --------------------------------------------------------------------------- + describe('zoom constants', () => { + it('MAX_ZOOM_LEVEL is greater than MIN_ZOOM_LEVEL', () => { + expect(MAX_ZOOM_LEVEL).toBeGreaterThan(MIN_ZOOM_LEVEL); + }); + + it('MIN_ZOOM_LEVEL is a positive number', () => { + expect(MIN_ZOOM_LEVEL).toBeGreaterThan(0); + }); + + it('ZOOM_STEP is a positive number less than the zoom range', () => { + expect(ZOOM_STEP).toBeGreaterThan(0); + expect(ZOOM_STEP).toBeLessThan(MAX_ZOOM_LEVEL - MIN_ZOOM_LEVEL); + }); + }); + + // --------------------------------------------------------------------------- + // Misc boolean/number constants + // --------------------------------------------------------------------------- + describe('miscellaneous constants', () => { + it('SHOW_UNREAD_STATUS_BADGE is a boolean', () => { + expect(typeof SHOW_UNREAD_STATUS_BADGE).toBe('boolean'); + }); + + it('DOUBLE_CLICK_INTERVAL_THRESHOLD_MS is a positive number', () => { + expect(typeof DOUBLE_CLICK_INTERVAL_THRESHOLD_MS).toBe('number'); + expect(DOUBLE_CLICK_INTERVAL_THRESHOLD_MS).toBeGreaterThan(0); + expect(DOUBLE_CLICK_INTERVAL_THRESHOLD_MS).toBeLessThanOrEqual(1000); + }); + + it('DISABLE_DOUBLE_CLICK_ON_MOBILE is a boolean', () => { + expect(typeof DISABLE_DOUBLE_CLICK_ON_MOBILE).toBe('boolean'); + }); + + it('LONG_HOLD_THRESHOLD is a positive number', () => { + expect(typeof LONG_HOLD_THRESHOLD).toBe('number'); + expect(LONG_HOLD_THRESHOLD).toBeGreaterThan(0); + expect(LONG_HOLD_THRESHOLD).toBeLessThanOrEqual(5000); + }); + + it('SIZE_PER_LOC is a positive number', () => { + expect(typeof SIZE_PER_LOC).toBe('number'); + expect(SIZE_PER_LOC).toBeGreaterThan(0); + }); + + it('SIZE_PER_TIME_UNIT is a positive number', () => { + expect(typeof SIZE_PER_TIME_UNIT).toBe('number'); + expect(SIZE_PER_TIME_UNIT).toBeGreaterThan(0); + }); + }); + + // --------------------------------------------------------------------------- + // Quota constants + // --------------------------------------------------------------------------- + describe('quota constants', () => { + it('DEFAULT_STORAGE_QUOTA has all plan tiers', () => { + expect(typeof DEFAULT_STORAGE_QUOTA).toBe('object'); + expect(typeof DEFAULT_STORAGE_QUOTA.free).toBe('number'); + expect(typeof DEFAULT_STORAGE_QUOTA.plus).toBe('number'); + expect(typeof DEFAULT_STORAGE_QUOTA.pro).toBe('number'); + expect(typeof DEFAULT_STORAGE_QUOTA.purchase).toBe('number'); + }); + + it('DEFAULT_STORAGE_QUOTA tiers are in ascending order (except purchase)', () => { + expect(DEFAULT_STORAGE_QUOTA.free).toBeGreaterThan(0); + expect(DEFAULT_STORAGE_QUOTA.plus).toBeGreaterThan(DEFAULT_STORAGE_QUOTA.free); + expect(DEFAULT_STORAGE_QUOTA.pro).toBeGreaterThan(DEFAULT_STORAGE_QUOTA.plus); + }); + + it('DEFAULT_DAILY_TRANSLATION_QUOTA has all plan tiers', () => { + expect(typeof DEFAULT_DAILY_TRANSLATION_QUOTA).toBe('object'); + expect(typeof DEFAULT_DAILY_TRANSLATION_QUOTA.free).toBe('number'); + expect(typeof DEFAULT_DAILY_TRANSLATION_QUOTA.plus).toBe('number'); + expect(typeof DEFAULT_DAILY_TRANSLATION_QUOTA.pro).toBe('number'); + expect(typeof DEFAULT_DAILY_TRANSLATION_QUOTA.purchase).toBe('number'); + }); + + it('DEFAULT_DAILY_TRANSLATION_QUOTA tiers are in ascending order (except purchase)', () => { + expect(DEFAULT_DAILY_TRANSLATION_QUOTA.free).toBeGreaterThan(0); + expect(DEFAULT_DAILY_TRANSLATION_QUOTA.plus).toBeGreaterThan( + DEFAULT_DAILY_TRANSLATION_QUOTA.free, + ); + expect(DEFAULT_DAILY_TRANSLATION_QUOTA.pro).toBeGreaterThan( + DEFAULT_DAILY_TRANSLATION_QUOTA.plus, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Custom theme templates + // --------------------------------------------------------------------------- + describe('CUSTOM_THEME_TEMPLATES', () => { + it('is a non-empty array', () => { + expect(Array.isArray(CUSTOM_THEME_TEMPLATES)).toBe(true); + expect(CUSTOM_THEME_TEMPLATES.length).toBeGreaterThan(0); + }); + + it('each template has light and dark variants with fg, bg, and primary', () => { + for (const template of CUSTOM_THEME_TEMPLATES) { + expect(typeof template.light).toBe('object'); + expect(typeof template.dark).toBe('object'); + + for (const variant of [template.light, template.dark]) { + expect(typeof variant.fg).toBe('string'); + expect(variant.fg).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(typeof variant.bg).toBe('string'); + expect(variant.bg).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(typeof variant.primary).toBe('string'); + expect(variant.primary).toMatch(/^#[0-9a-fA-F]{6}$/); + } + } + }); + }); + + // --------------------------------------------------------------------------- + // RTL languages + // --------------------------------------------------------------------------- + describe('MIGHT_BE_RTL_LANGS', () => { + it('is a non-empty array of strings', () => { + expect(Array.isArray(MIGHT_BE_RTL_LANGS)).toBe(true); + expect(MIGHT_BE_RTL_LANGS.length).toBeGreaterThan(0); + for (const lang of MIGHT_BE_RTL_LANGS) { + expect(typeof lang).toBe('string'); + } + }); + + it('includes common RTL language codes', () => { + expect(MIGHT_BE_RTL_LANGS).toContain('ar'); + expect(MIGHT_BE_RTL_LANGS).toContain('he'); + expect(MIGHT_BE_RTL_LANGS).toContain('fa'); + }); + }); + + // --------------------------------------------------------------------------- + // Language maps + // --------------------------------------------------------------------------- + describe('language maps', () => { + it('TRANSLATED_LANGS is a record of language codes to names', () => { + expect(typeof TRANSLATED_LANGS).toBe('object'); + const entries = Object.entries(TRANSLATED_LANGS); + expect(entries.length).toBeGreaterThan(0); + for (const [code, name] of entries) { + expect(typeof code).toBe('string'); + expect(code.length).toBeGreaterThan(0); + expect(typeof name).toBe('string'); + expect(name.length).toBeGreaterThan(0); + } + }); + + it('TRANSLATED_LANGS includes English', () => { + expect(TRANSLATED_LANGS.en).toBe('English'); + }); + + it('TRANSLATED_LANGS includes Chinese variants', () => { + expect(TRANSLATED_LANGS['zh-CN']).toBeDefined(); + expect(TRANSLATED_LANGS['zh-TW']).toBeDefined(); + }); + + it('TRANSLATOR_LANGS is a superset of TRANSLATED_LANGS', () => { + expect(typeof TRANSLATOR_LANGS).toBe('object'); + for (const code of Object.keys(TRANSLATED_LANGS)) { + expect(code in TRANSLATOR_LANGS).toBe(true); + } + // TRANSLATOR_LANGS has extra languages + expect(Object.keys(TRANSLATOR_LANGS).length).toBeGreaterThanOrEqual( + Object.keys(TRANSLATED_LANGS).length, + ); + }); + + it('TRANSLATOR_LANGS includes additional languages not in TRANSLATED_LANGS', () => { + expect(TRANSLATOR_LANGS['nb']).toBeDefined(); + expect(TRANSLATOR_LANGS['sv']).toBeDefined(); + expect(TRANSLATOR_LANGS['fi']).toBeDefined(); + }); + + it('SUPPORTED_LANGS includes zh in addition to TRANSLATED_LANGS entries', () => { + expect(typeof SUPPORTED_LANGS).toBe('object'); + expect(SUPPORTED_LANGS['zh']).toBeDefined(); + // All TRANSLATED_LANGS should be in SUPPORTED_LANGS + for (const code of Object.keys(TRANSLATED_LANGS)) { + expect(code in SUPPORTED_LANGS).toBe(true); + } + }); + + it('SUPPORTED_LANGNAMES is the inverse of SUPPORTED_LANGS', () => { + expect(typeof SUPPORTED_LANGNAMES).toBe('object'); + const entries = Object.entries(SUPPORTED_LANGNAMES); + expect(entries.length).toBeGreaterThan(0); + // Each value in SUPPORTED_LANGNAMES should be a key in SUPPORTED_LANGS + for (const [name, code] of entries) { + expect(typeof name).toBe('string'); + expect(typeof code).toBe('string'); + expect(SUPPORTED_LANGS[code]).toBe(name); + } + }); + + it('SUPPORTED_LANGNAMES has the same number of entries as SUPPORTED_LANGS', () => { + expect(Object.keys(SUPPORTED_LANGNAMES).length).toBe(Object.keys(SUPPORTED_LANGS).length); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts new file mode 100644 index 00000000..9e229b03 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/edge-tts-client.test.ts @@ -0,0 +1,380 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Shared mock control: tests can override createBehavior to change how create() behaves +let createBehavior: () => Promise = () => Promise.resolve(undefined); + +// --- Mocks --- + +vi.mock('@/libs/edgeTTS', () => { + const voices = [ + { id: 'en-US-AriaNeural', name: 'Aria', lang: 'en-US' }, + { id: 'en-US-AnaNeural', name: 'Ana', lang: 'en-US' }, + { id: 'en-GB-SoniaNeural', name: 'Sonia', lang: 'en-GB' }, + { id: 'fr-FR-DeniseNeural', name: 'Denise', lang: 'fr-FR' }, + ]; + return { + EdgeSpeechTTS: class MockEdgeSpeechTTS { + static voices = voices; + create = vi.fn().mockImplementation(() => createBehavior()); + createAudioUrl = vi.fn().mockResolvedValue('blob:mock-url'); + }, + EDGE_TTS_PROTOCOL: 'wss', + }; +}); + +vi.mock('@/utils/ssml', () => ({ + parseSSMLMarks: vi.fn(() => ({ marks: [] })), +})); + +vi.mock('@/utils/misc', () => ({ + getUserLocale: vi.fn((lang: string) => (lang === 'en' ? 'en-US' : lang)), +})); + +vi.mock('@/services/tts/TTSUtils', () => ({ + TTSUtils: { + getPreferredVoice: vi.fn(() => null), + sortVoicesFunc: (a: { id: string }, b: { id: string }) => a.id.localeCompare(b.id), + }, +})); + +import { EdgeTTSClient } from '@/services/tts/EdgeTTSClient'; +import { TTSController } from '@/services/tts/TTSController'; + +// Suppress console noise during tests +const consoleSpy = { + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + log: vi.spyOn(console, 'log').mockImplementation(() => {}), +}; +void consoleSpy; + +describe('EdgeTTSClient', () => { + let client: EdgeTTSClient; + + beforeEach(() => { + createBehavior = () => Promise.resolve(undefined); + client = new EdgeTTSClient(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + test('sets name to edge-tts', () => { + expect(client.name).toBe('edge-tts'); + }); + + test('starts uninitialized', () => { + expect(client.initialized).toBe(false); + }); + + test('stores controller and appService when provided', () => { + const mockController = {} as TTSController; + const mockAppService = { isLinuxApp: false } as never; + const c = new EdgeTTSClient(mockController, mockAppService); + expect(c.controller).toBe(mockController); + expect(c.appService).toBe(mockAppService); + }); + + test('controller and appService are undefined when not provided', () => { + expect(client.controller).toBeUndefined(); + expect(client.appService).toBeUndefined(); + }); + }); + + describe('init', () => { + test('succeeds when create resolves and sets initialized to true', async () => { + const result = await client.init(); + expect(result).toBe(true); + expect(client.initialized).toBe(true); + }); + + test('populates voices from EdgeSpeechTTS.voices on init', async () => { + await client.init(); + const voices = await client.getAllVoices(); + expect(voices).toHaveLength(4); + expect(voices.map((v) => v.id)).toContain('en-US-AriaNeural'); + }); + + test('wss failure falls back to https when controller is authenticated', async () => { + const mockController = { + isAuthenticated: true, + dispatchEvent: vi.fn(), + } as unknown as TTSController; + const c = new EdgeTTSClient(mockController); + + // First call (wss protocol) fails, second call (https fallback) succeeds + let callCount = 0; + createBehavior = () => { + callCount++; + if (callCount === 1) return Promise.reject(new Error('wss failed')); + return Promise.resolve(undefined); + }; + + const result = await c.init(); + expect(result).toBe(true); + expect(c.initialized).toBe(true); + // Two calls: initial wss attempt + https fallback + expect(callCount).toBe(2); + }); + + test('wss failure dispatches tts-need-auth when not authenticated', async () => { + const dispatchEvent = vi.fn(); + const mockController = { + isAuthenticated: false, + dispatchEvent, + } as unknown as TTSController; + const c = new EdgeTTSClient(mockController); + + createBehavior = () => Promise.reject(new Error('wss failed')); + + const result = await c.init(); + expect(result).toBe(false); + expect(dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'tts-need-auth' }), + ); + }); + + test('https failure sets initialized to false', async () => { + const mockController = { + isAuthenticated: true, + dispatchEvent: vi.fn(), + } as unknown as TTSController; + const c = new EdgeTTSClient(mockController); + + // Both wss and https always fail + createBehavior = () => Promise.reject(new Error('failed')); + + const result = await c.init(); + expect(result).toBe(false); + expect(c.initialized).toBe(false); + }); + }); + + describe('setRate', () => { + test('stores rate value', async () => { + await client.setRate(1.5); + // Rate is private, so we verify indirectly - no error thrown + await expect(client.setRate(0.5)).resolves.toBeUndefined(); + }); + + test('accepts boundary values', async () => { + await expect(client.setRate(0.5)).resolves.toBeUndefined(); + await expect(client.setRate(2.0)).resolves.toBeUndefined(); + }); + }); + + describe('setPitch', () => { + test('stores pitch value', async () => { + await expect(client.setPitch(1.2)).resolves.toBeUndefined(); + }); + + test('accepts boundary values', async () => { + await expect(client.setPitch(0.5)).resolves.toBeUndefined(); + await expect(client.setPitch(1.5)).resolves.toBeUndefined(); + }); + }); + + describe('setVoice', () => { + test('sets voice when voice id exists in voice list', async () => { + await client.init(); + await client.setVoice('en-US-AriaNeural'); + expect(client.getVoiceId()).toBe('en-US-AriaNeural'); + }); + + test('does not change voice id when voice id is not found', async () => { + await client.init(); + await client.setVoice('en-US-AriaNeural'); + await client.setVoice('nonexistent-voice'); + expect(client.getVoiceId()).toBe('en-US-AriaNeural'); + }); + + test('voice id remains empty when no voice has been set', () => { + expect(client.getVoiceId()).toBe(''); + }); + }); + + describe('setPrimaryLang', () => { + test('sets primary language', () => { + client.setPrimaryLang('fr'); + // No public getter for primaryLang, but we verify no error + // The effect is observed when speak() uses it + }); + + test('accepts any language string', () => { + client.setPrimaryLang('zh-CN'); + client.setPrimaryLang('ja'); + client.setPrimaryLang('en'); + // No error thrown + }); + }); + + describe('getGranularities', () => { + test('returns array with sentence granularity only', () => { + const granularities = client.getGranularities(); + expect(granularities).toEqual(['sentence']); + }); + + test('returns the same value regardless of initialization', async () => { + const before = client.getGranularities(); + await client.init(); + const after = client.getGranularities(); + expect(before).toEqual(after); + }); + }); + + describe('getVoiceId', () => { + test('returns empty string by default', () => { + expect(client.getVoiceId()).toBe(''); + }); + + test('returns the set voice id after setVoice', async () => { + await client.init(); + await client.setVoice('fr-FR-DeniseNeural'); + expect(client.getVoiceId()).toBe('fr-FR-DeniseNeural'); + }); + }); + + describe('getSpeakingLang', () => { + test('returns empty string by default', () => { + expect(client.getSpeakingLang()).toBe(''); + }); + }); + + describe('getAllVoices', () => { + test('returns voices from EdgeSpeechTTS after init', async () => { + await client.init(); + const voices = await client.getAllVoices(); + expect(voices).toHaveLength(4); + expect(voices[0]!.id).toBe('en-US-AriaNeural'); + }); + + test('marks voices as disabled when not initialized', async () => { + // Do NOT call init + const voices = await client.getAllVoices(); + for (const voice of voices) { + expect(voice.disabled).toBe(true); + } + }); + + test('marks voices as enabled when initialized', async () => { + await client.init(); + const voices = await client.getAllVoices(); + for (const voice of voices) { + expect(voice.disabled).toBe(false); + } + }); + + test('returns empty array before init since voices are assigned during init', async () => { + // Before init, #voices is the empty default + const voices = await client.getAllVoices(); + // Actually, the constructor doesn't call init, so #voices starts as [] + // But wait - init sets #voices = EdgeSpeechTTS.voices. Without init, it stays []. + // However, getAllVoices returns this.#voices which starts as []. + // Let's check: the mock voices are set on static, not on the instance default. + expect(voices).toHaveLength(0); + }); + }); + + describe('getVoices', () => { + beforeEach(async () => { + await client.init(); + }); + + test('filters voices by language prefix', async () => { + const groups = await client.getVoices('fr-FR'); + expect(groups).toHaveLength(1); + expect(groups[0]!.id).toBe('edge-tts'); + expect(groups[0]!.name).toBe('Edge TTS'); + expect(groups[0]!.voices).toHaveLength(1); + expect(groups[0]!.voices[0]!.id).toBe('fr-FR-DeniseNeural'); + }); + + test('handles "en" by expanding to locale and including en-US and en-GB', async () => { + const groups = await client.getVoices('en'); + const voiceIds = groups[0]!.voices.map((v) => v.id); + expect(voiceIds).toContain('en-US-AriaNeural'); + expect(voiceIds).toContain('en-US-AnaNeural'); + expect(voiceIds).toContain('en-GB-SoniaNeural'); + }); + + test('returns sorted voices using TTSUtils.sortVoicesFunc', async () => { + const groups = await client.getVoices('en'); + const voiceIds = groups[0]!.voices.map((v) => v.id); + const sorted = [...voiceIds].sort(); + expect(voiceIds).toEqual(sorted); + }); + + test('marks group as disabled when not initialized', async () => { + const uninitClient = new EdgeTTSClient(); + // We need voices to be populated but not initialized + // Since uninitClient hasn't called init, #voices is empty + const groups = await uninitClient.getVoices('en'); + expect(groups[0]!.disabled).toBe(true); + }); + + test('marks group as disabled when no matching voices found', async () => { + const groups = await client.getVoices('zh-CN'); + expect(groups[0]!.disabled).toBe(true); + expect(groups[0]!.voices).toHaveLength(0); + }); + + test('returns group not disabled when initialized and voices match', async () => { + const groups = await client.getVoices('en'); + expect(groups[0]!.disabled).toBe(false); + }); + }); + + describe('shutdown', () => { + test('sets initialized to false', async () => { + await client.init(); + expect(client.initialized).toBe(true); + await client.shutdown(); + expect(client.initialized).toBe(false); + }); + + test('clears the voice list', async () => { + await client.init(); + const voicesBefore = await client.getAllVoices(); + expect(voicesBefore.length).toBeGreaterThan(0); + + await client.shutdown(); + const voicesAfter = await client.getAllVoices(); + expect(voicesAfter).toHaveLength(0); + }); + + test('can be called multiple times without error', async () => { + await client.shutdown(); + await client.shutdown(); + expect(client.initialized).toBe(false); + }); + + test('can re-initialize after shutdown', async () => { + await client.init(); + await client.shutdown(); + expect(client.initialized).toBe(false); + + await client.init(); + expect(client.initialized).toBe(true); + const voices = await client.getAllVoices(); + expect(voices.length).toBeGreaterThan(0); + }); + }); + + describe('pause / resume / stop', () => { + test('pause returns true when no audio element exists', async () => { + const result = await client.pause(); + expect(result).toBe(true); + }); + + test('resume returns true when no audio element exists', async () => { + const result = await client.resume(); + expect(result).toBe(true); + }); + + test('stop resolves without error when no audio element exists', async () => { + await expect(client.stop()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/environment.test.ts b/apps/readest-app/src/__tests__/services/environment.test.ts new file mode 100644 index 00000000..27861727 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/environment.test.ts @@ -0,0 +1,250 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; + +// ── Mocks for constants ────────────────────────────────────────── +vi.mock('@/services/constants', () => ({ + READEST_WEB_BASE_URL: 'https://web.readest.com', + READEST_NODE_BASE_URL: 'https://node.readest.com', +})); + +// We need to reset modules between tests to pick up env var changes, +// so we import dynamically in each test or test group. + +// Cast process.env to a mutable record for test manipulation +const env = process.env as Record; +const originalEnv = { ...env }; + +beforeEach(() => { + vi.resetModules(); + Object.keys(env).forEach((key) => delete env[key]); + Object.assign(env, originalEnv); + // Clean up any window globals we set + delete (window as unknown as Record)['__READEST_CLI_ACCESS']; +}); + +describe('environment', () => { + // ── isTauriAppPlatform ───────────────────────────────────────── + describe('isTauriAppPlatform', () => { + test('returns true when NEXT_PUBLIC_APP_PLATFORM is tauri', async () => { + env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri'; + const { isTauriAppPlatform } = await import('@/services/environment'); + expect(isTauriAppPlatform()).toBe(true); + }); + + test('returns false when NEXT_PUBLIC_APP_PLATFORM is web', async () => { + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + const { isTauriAppPlatform } = await import('@/services/environment'); + expect(isTauriAppPlatform()).toBe(false); + }); + + test('returns false when NEXT_PUBLIC_APP_PLATFORM is not set', async () => { + delete env['NEXT_PUBLIC_APP_PLATFORM']; + const { isTauriAppPlatform } = await import('@/services/environment'); + expect(isTauriAppPlatform()).toBe(false); + }); + }); + + // ── isWebAppPlatform ─────────────────────────────────────────── + describe('isWebAppPlatform', () => { + test('returns true when NEXT_PUBLIC_APP_PLATFORM is web', async () => { + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + const { isWebAppPlatform } = await import('@/services/environment'); + expect(isWebAppPlatform()).toBe(true); + }); + + test('returns false when NEXT_PUBLIC_APP_PLATFORM is tauri', async () => { + env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri'; + const { isWebAppPlatform } = await import('@/services/environment'); + expect(isWebAppPlatform()).toBe(false); + }); + + test('returns false when NEXT_PUBLIC_APP_PLATFORM is not set', async () => { + delete env['NEXT_PUBLIC_APP_PLATFORM']; + const { isWebAppPlatform } = await import('@/services/environment'); + expect(isWebAppPlatform()).toBe(false); + }); + }); + + // ── hasCli ───────────────────────────────────────────────────── + describe('hasCli', () => { + test('returns true when __READEST_CLI_ACCESS is true', async () => { + window.__READEST_CLI_ACCESS = true; + const { hasCli } = await import('@/services/environment'); + expect(hasCli()).toBe(true); + }); + + test('returns false when __READEST_CLI_ACCESS is not set', async () => { + const { hasCli } = await import('@/services/environment'); + expect(hasCli()).toBe(false); + }); + + test('returns false when __READEST_CLI_ACCESS is explicitly false', async () => { + window.__READEST_CLI_ACCESS = false; + const { hasCli } = await import('@/services/environment'); + expect(hasCli()).toBe(false); + }); + }); + + // ── isPWA ────────────────────────────────────────────────────── + describe('isPWA', () => { + test('returns false by default (jsdom matchMedia mock returns false)', async () => { + const { isPWA } = await import('@/services/environment'); + expect(isPWA()).toBe(false); + }); + + test('returns true when display-mode is standalone', async () => { + const originalMatchMedia = window.matchMedia; + window.matchMedia = vi + .fn() + .mockReturnValue({ matches: true }) as unknown as typeof window.matchMedia; + + const { isPWA } = await import('@/services/environment'); + expect(isPWA()).toBe(true); + + window.matchMedia = originalMatchMedia; + }); + }); + + // ── getBaseUrl ───────────────────────────────────────────────── + describe('getBaseUrl', () => { + test('returns NEXT_PUBLIC_API_BASE_URL when set', async () => { + env['NEXT_PUBLIC_API_BASE_URL'] = 'https://custom-api.example.com'; + const { getBaseUrl } = await import('@/services/environment'); + expect(getBaseUrl()).toBe('https://custom-api.example.com'); + }); + + test('falls back to READEST_WEB_BASE_URL when env var not set', async () => { + delete env['NEXT_PUBLIC_API_BASE_URL']; + const { getBaseUrl } = await import('@/services/environment'); + expect(getBaseUrl()).toBe('https://web.readest.com'); + }); + }); + + // ── getNodeBaseUrl ───────────────────────────────────────────── + describe('getNodeBaseUrl', () => { + test('returns NEXT_PUBLIC_NODE_BASE_URL when set', async () => { + env['NEXT_PUBLIC_NODE_BASE_URL'] = 'https://custom-node.example.com'; + const { getNodeBaseUrl } = await import('@/services/environment'); + expect(getNodeBaseUrl()).toBe('https://custom-node.example.com'); + }); + + test('falls back to READEST_NODE_BASE_URL when env var not set', async () => { + delete env['NEXT_PUBLIC_NODE_BASE_URL']; + const { getNodeBaseUrl } = await import('@/services/environment'); + expect(getNodeBaseUrl()).toBe('https://node.readest.com'); + }); + }); + + // ── isMacPlatform ────────────────────────────────────────────── + describe('isMacPlatform', () => { + test('returns true when navigator.platform contains Mac', async () => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(true); + }); + + test('returns true when navigator.platform is iPhone', async () => { + Object.defineProperty(navigator, 'platform', { value: 'iPhone', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(true); + }); + + test('returns true when navigator.platform is iPad', async () => { + Object.defineProperty(navigator, 'platform', { value: 'iPad', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(true); + }); + + test('returns true when navigator.platform is iPod', async () => { + Object.defineProperty(navigator, 'platform', { value: 'iPod', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(true); + }); + + test('returns false when navigator.platform is Win32', async () => { + Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(false); + }); + + test('returns false when navigator.platform is Linux', async () => { + Object.defineProperty(navigator, 'platform', { value: 'Linux x86_64', configurable: true }); + const { isMacPlatform } = await import('@/services/environment'); + expect(isMacPlatform()).toBe(false); + }); + }); + + // ── getCommandPaletteShortcut ────────────────────────────────── + describe('getCommandPaletteShortcut', () => { + test('returns Mac shortcut on Mac platforms', async () => { + Object.defineProperty(navigator, 'platform', { value: 'MacIntel', configurable: true }); + const { getCommandPaletteShortcut } = await import('@/services/environment'); + expect(getCommandPaletteShortcut()).toContain('P'); + }); + + test('returns Ctrl shortcut on non-Mac platforms', async () => { + Object.defineProperty(navigator, 'platform', { value: 'Win32', configurable: true }); + const { getCommandPaletteShortcut } = await import('@/services/environment'); + expect(getCommandPaletteShortcut()).toBe('Ctrl+Shift+P'); + }); + }); + + // ── getAPIBaseUrl ────────────────────────────────────────────── + describe('getAPIBaseUrl', () => { + test('returns /api in web development mode', async () => { + env['NODE_ENV'] = 'development'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + const { getAPIBaseUrl } = await import('@/services/environment'); + expect(getAPIBaseUrl()).toBe('/api'); + }); + + test('returns full URL in production', async () => { + env['NODE_ENV'] = 'production'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + delete env['NEXT_PUBLIC_API_BASE_URL']; + const { getAPIBaseUrl } = await import('@/services/environment'); + expect(getAPIBaseUrl()).toBe('https://web.readest.com/api'); + }); + + test('returns full URL for tauri platform even in development', async () => { + env['NODE_ENV'] = 'development'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri'; + delete env['NEXT_PUBLIC_API_BASE_URL']; + const { getAPIBaseUrl } = await import('@/services/environment'); + expect(getAPIBaseUrl()).toBe('https://web.readest.com/api'); + }); + }); + + // ── getNodeAPIBaseUrl ────────────────────────────────────────── + describe('getNodeAPIBaseUrl', () => { + test('returns /api in web development mode', async () => { + env['NODE_ENV'] = 'development'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + const { getNodeAPIBaseUrl } = await import('@/services/environment'); + expect(getNodeAPIBaseUrl()).toBe('/api'); + }); + + test('returns full node URL in production', async () => { + env['NODE_ENV'] = 'production'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'web'; + delete env['NEXT_PUBLIC_NODE_BASE_URL']; + const { getNodeAPIBaseUrl } = await import('@/services/environment'); + expect(getNodeAPIBaseUrl()).toBe('https://node.readest.com/api'); + }); + + test('returns full node URL for tauri platform even in development', async () => { + env['NODE_ENV'] = 'development'; + env['NEXT_PUBLIC_APP_PLATFORM'] = 'tauri'; + delete env['NEXT_PUBLIC_NODE_BASE_URL']; + const { getNodeAPIBaseUrl } = await import('@/services/environment'); + expect(getNodeAPIBaseUrl()).toBe('https://node.readest.com/api'); + }); + }); + + // ── environmentConfig default export ─────────────────────────── + describe('environmentConfig', () => { + test('exports an object with getAppService function', async () => { + const envConfig = await import('@/services/environment'); + expect(typeof envConfig.default.getAppService).toBe('function'); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts new file mode 100644 index 00000000..8f419535 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/rsvp-utils.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect } from 'vitest'; +import { + isCJK, + containsCJK, + isCJKPunctuation, + getSegmenterLocale, + segmentCJKText, + splitTextIntoWords, +} from '@/services/rsvp/utils'; + +describe('rsvp/utils', () => { + describe('isCJK', () => { + test('returns true for CJK Unified Ideographs', () => { + expect(isCJK('\u4e00')).toBe(true); // first CJK character + expect(isCJK('\u9fff')).toBe(true); // last CJK character + expect(isCJK('\u5f00')).toBe(true); // 开 + }); + + test('returns true for Hiragana', () => { + expect(isCJK('\u3042')).toBe(true); // あ + }); + + test('returns true for Katakana', () => { + expect(isCJK('\u30A2')).toBe(true); // ア + }); + + test('returns true for Hangul', () => { + expect(isCJK('\uAC00')).toBe(true); // 가 + }); + + test('returns true for CJK Extension A', () => { + expect(isCJK('\u3400')).toBe(true); + }); + + test('returns true for CJK Compatibility Ideographs', () => { + expect(isCJK('\uF900')).toBe(true); + }); + + test('returns false for Latin characters', () => { + expect(isCJK('a')).toBe(false); + expect(isCJK('Z')).toBe(false); + }); + + test('returns false for digits', () => { + expect(isCJK('1')).toBe(false); + }); + + test('returns false for spaces', () => { + expect(isCJK(' ')).toBe(false); + }); + }); + + describe('containsCJK', () => { + test('returns true for text with CJK characters', () => { + expect(containsCJK('Hello 你好')).toBe(true); + }); + + test('returns true for pure CJK text', () => { + expect(containsCJK('你好世界')).toBe(true); + }); + + test('returns false for pure Latin text', () => { + expect(containsCJK('Hello World')).toBe(false); + }); + + test('returns false for empty string', () => { + expect(containsCJK('')).toBe(false); + }); + + test('returns true for Japanese hiragana', () => { + expect(containsCJK('こんにちは')).toBe(true); + }); + + test('returns true for Korean', () => { + expect(containsCJK('안녕하세요')).toBe(true); + }); + }); + + describe('isCJKPunctuation', () => { + test('returns true for Chinese period', () => { + expect(isCJKPunctuation('。')).toBe(true); + }); + + test('returns true for Chinese comma', () => { + expect(isCJKPunctuation(',')).toBe(true); + }); + + test('returns true for full-width exclamation', () => { + expect(isCJKPunctuation('!')).toBe(true); + }); + + test('returns true for full-width question mark', () => { + expect(isCJKPunctuation('?')).toBe(true); + }); + + test('returns true for brackets', () => { + expect(isCJKPunctuation('【')).toBe(true); + expect(isCJKPunctuation('】')).toBe(true); + expect(isCJKPunctuation('「')).toBe(true); + expect(isCJKPunctuation('」')).toBe(true); + }); + + test('returns true for ellipsis', () => { + expect(isCJKPunctuation('…')).toBe(true); + }); + + test('returns false for standard Latin comma', () => { + expect(isCJKPunctuation(',')).toBe(false); + }); + + test('returns false for Latin period', () => { + expect(isCJKPunctuation('.')).toBe(false); + }); + + test('returns false for a letter', () => { + expect(isCJKPunctuation('A')).toBe(false); + }); + }); + + describe('getSegmenterLocale', () => { + test('returns ja for Japanese hiragana text', () => { + expect(getSegmenterLocale('こんにちは')).toBe('ja'); + }); + + test('returns ja for Katakana text', () => { + expect(getSegmenterLocale('アイウ')).toBe('ja'); + }); + + test('returns ko for Korean text', () => { + expect(getSegmenterLocale('안녕하세요')).toBe('ko'); + }); + + test('returns zh for Chinese text', () => { + expect(getSegmenterLocale('你好世界')).toBe('zh'); + }); + + test('returns null for pure Latin text', () => { + expect(getSegmenterLocale('Hello World')).toBeNull(); + }); + + test('returns null for empty string', () => { + expect(getSegmenterLocale('')).toBeNull(); + }); + + test('detects first CJK script in mixed text', () => { + // Japanese hiragana appears first + expect(getSegmenterLocale('あ你好')).toBe('ja'); + }); + }); + + describe('segmentCJKText', () => { + test('segments Chinese text into words', () => { + const words = segmentCJKText('你好世界'); + expect(words.length).toBeGreaterThan(0); + expect(words.join('')).toContain('你好'); + }); + + test('segments Japanese text', () => { + const words = segmentCJKText('こんにちは'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles text with punctuation', () => { + const words = segmentCJKText('你好。世界!'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles empty text', () => { + const words = segmentCJKText(''); + expect(words).toEqual([]); + }); + + test('handles single character', () => { + const words = segmentCJKText('你'); + expect(words.length).toBeGreaterThanOrEqual(1); + }); + + test('attaches trailing CJK punctuation', () => { + const words = segmentCJKText('你好!'); + // The punctuation should be attached to a word + const hasWordWithPunct = words.some((w) => w.includes('!')); + expect(hasWordWithPunct).toBe(true); + }); + }); + + describe('splitTextIntoWords', () => { + test('splits English text by spaces', () => { + const words = splitTextIntoWords('Hello World'); + expect(words).toEqual(['Hello', 'World']); + }); + + test('splits multi-word English text', () => { + const words = splitTextIntoWords('The quick brown fox'); + expect(words).toEqual(['The', 'quick', 'brown', 'fox']); + }); + + test('filters empty words', () => { + const words = splitTextIntoWords(' Hello World '); + expect(words.every((w) => w.trim().length > 0)).toBe(true); + }); + + test('handles CJK text', () => { + const words = splitTextIntoWords('你好世界'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles mixed CJK and Latin text', () => { + const words = splitTextIntoWords('Hello 你好 World'); + expect(words.length).toBeGreaterThan(0); + // Should contain both CJK and Latin segments + }); + + test('handles empty string', () => { + const words = splitTextIntoWords(''); + expect(words).toEqual([]); + }); + + test('handles CJK text with punctuation', () => { + const words = splitTextIntoWords('你好。世界!'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles CJK followed immediately by non-CJK', () => { + const words = splitTextIntoWords('你好Hello'); + expect(words.length).toBeGreaterThanOrEqual(1); + }); + + test('handles standalone CJK punctuation at start', () => { + const words = splitTextIntoWords('。Hello'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles non-CJK text followed by CJK punctuation', () => { + const words = splitTextIntoWords('Hello。'); + expect(words.length).toBeGreaterThan(0); + }); + + test('handles whitespace between CJK segments', () => { + const words = splitTextIntoWords('你好 世界'); + expect(words.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts new file mode 100644 index 00000000..c7fb920d --- /dev/null +++ b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts @@ -0,0 +1,674 @@ +import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { useTransferStore, TransferItem } from '@/store/transferStore'; + +// ── Mocks ──────────────────────────────────────────────────────────── +// The transferManager module is a singleton, so we need to mock its +// dependencies before importing it. + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + dispatch: vi.fn(), + }, +})); + +// After the module-level mock declarations, import the SUT +import { transferManager } from '@/services/transferManager'; +import { eventDispatcher } from '@/utils/event'; +import type { Book } from '@/types/book'; + +// ── Helpers ────────────────────────────────────────────────────────── +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'hash1', + format: 'EPUB', + title: 'Test Book', + author: 'Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +function makeTransferItem(overrides: Partial = {}): TransferItem { + return { + id: 't1', + bookHash: 'hash1', + bookTitle: 'Test Book', + type: 'upload', + status: 'pending', + progress: 0, + totalBytes: 0, + transferredBytes: 0, + transferSpeed: 0, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + priority: 10, + isBackground: false, + ...overrides, + }; +} + +// Reset the singleton and store before each test. +// Because TransferManager is a singleton with private isInitialized flag, +// we access the internal state via the exported instance. +const resetTransferManager = () => { + // Reset internal state via prototype hacking + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only introspection + const mgr = transferManager as unknown as Record; + mgr['isInitialized'] = false; + mgr['isProcessing'] = false; + mgr['appService'] = null; + mgr['getLibrary'] = null; + mgr['updateBook'] = null; + mgr['_'] = null; + (mgr['abortControllers'] as Map).clear(); +}; + +const resetTransferStore = () => { + useTransferStore.setState({ + transfers: {}, + isQueuePaused: false, + isTransferQueueOpen: false, + maxConcurrent: 2, + activeCount: 0, + }); +}; + +// Minimal AppService mock +function makeAppService() { + return { + uploadBook: vi.fn().mockResolvedValue(undefined), + downloadBook: vi.fn().mockResolvedValue(undefined), + deleteBook: vi.fn().mockResolvedValue(undefined), + isMacOSApp: false, + } as Record; +} + +const translationFn = (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}; + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + resetTransferStore(); + resetTransferManager(); + vi.clearAllMocks(); + localStorage.clear(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Tests ──────────────────────────────────────────────────────────── +describe('TransferManager', () => { + // ── Singleton ──────────────────────────────────────────────────── + describe('getInstance / singleton', () => { + test('always returns the same instance', async () => { + // The module export is the singleton; importing again should yield the same ref. + const { transferManager: tm2 } = await import('@/services/transferManager'); + expect(tm2).toBe(transferManager); + }); + }); + + // ── isReady ────────────────────────────────────────────────────── + describe('isReady', () => { + test('returns false before initialization', () => { + expect(transferManager.isReady()).toBe(false); + }); + + test('returns true after initialization', async () => { + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + expect(transferManager.isReady()).toBe(true); + }); + }); + + // ── initialize ─────────────────────────────────────────────────── + describe('initialize', () => { + test('loads persisted queue from localStorage', async () => { + const item = makeTransferItem({ id: 'persisted-1', status: 'pending' }); + const data = { + transfers: { 'persisted-1': item }, + isQueuePaused: true, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(data)); + + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + + const store = useTransferStore.getState(); + expect(store.transfers['persisted-1']).toBeDefined(); + expect(store.isQueuePaused).toBe(true); + }); + + test('is idempotent — second call is a no-op', async () => { + const appService = makeAppService(); + const getLibrary = () => [] as Book[]; + await transferManager.initialize(appService as never, getLibrary, vi.fn(), translationFn); + + // Change appService ref to detect if it gets overwritten + const appService2 = makeAppService(); + await transferManager.initialize(appService2 as never, getLibrary, vi.fn(), translationFn); + + // The first appService should still be in use + const mgr = transferManager as unknown as Record; + expect(mgr['appService']).toBe(appService); + }); + }); + + // ── queueUpload ────────────────────────────────────────────────── + describe('queueUpload', () => { + test('returns null when not initialized', () => { + const result = transferManager.queueUpload(makeBook()); + expect(result).toBeNull(); + }); + + test('queues an upload and returns a transfer id', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook()); + expect(id).toBeTruthy(); + expect(typeof id).toBe('string'); + + const transfer = useTransferStore.getState().transfers[id!]; + expect(transfer).toBeDefined(); + expect(transfer!.type).toBe('upload'); + expect(transfer!.bookHash).toBe('hash1'); + }); + + test('returns existing id if already queued', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id1 = transferManager.queueUpload(makeBook()); + const id2 = transferManager.queueUpload(makeBook()); + expect(id1).toBe(id2); + }); + + test('respects custom priority', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook(), 1); + const transfer = useTransferStore.getState().transfers[id!]; + expect(transfer!.priority).toBe(1); + }); + }); + + // ── queueDownload ──────────────────────────────────────────────── + describe('queueDownload', () => { + test('returns null when not initialized', () => { + const result = transferManager.queueDownload(makeBook()); + expect(result).toBeNull(); + }); + + test('queues a download and returns a transfer id', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueDownload(makeBook()); + expect(id).toBeTruthy(); + const transfer = useTransferStore.getState().transfers[id!]; + expect(transfer!.type).toBe('download'); + }); + + test('returns existing id if already queued', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id1 = transferManager.queueDownload(makeBook()); + const id2 = transferManager.queueDownload(makeBook()); + expect(id1).toBe(id2); + }); + }); + + // ── queueDelete ────────────────────────────────────────────────── + describe('queueDelete', () => { + test('returns null when not initialized', () => { + const result = transferManager.queueDelete(makeBook()); + expect(result).toBeNull(); + }); + + test('queues a delete and returns a transfer id', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueDelete(makeBook()); + expect(id).toBeTruthy(); + const transfer = useTransferStore.getState().transfers[id!]; + expect(transfer!.type).toBe('delete'); + }); + + test('supports isBackground flag', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueDelete(makeBook(), 10, true); + const transfer = useTransferStore.getState().transfers[id!]; + expect(transfer!.isBackground).toBe(true); + }); + }); + + // ── queueBatchUploads ──────────────────────────────────────────── + describe('queueBatchUploads', () => { + test('returns empty array when not initialized', () => { + const result = transferManager.queueBatchUploads([makeBook()]); + expect(result).toEqual([]); + }); + + test('queues multiple uploads', async () => { + const book1 = makeBook({ hash: 'h1', title: 'B1' }); + const book2 = makeBook({ hash: 'h2', title: 'B2' }); + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [book1, book2], + vi.fn(), + translationFn, + ); + + const ids = transferManager.queueBatchUploads([book1, book2]); + expect(ids).toHaveLength(2); + ids.forEach((id) => { + expect(useTransferStore.getState().transfers[id]).toBeDefined(); + }); + }); + }); + + // ── cancelTransfer ─────────────────────────────────────────────── + describe('cancelTransfer', () => { + test('sets status to cancelled', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook())!; + transferManager.cancelTransfer(id); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.status).toBe('cancelled'); + }); + + test('aborts an active abort controller', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook())!; + + // Manually inject an abort controller to simulate active transfer + const abortController = new AbortController(); + const abortSpy = vi.spyOn(abortController, 'abort'); + const mgr = transferManager as unknown as Record; + (mgr['abortControllers'] as Map).set(id, abortController); + + transferManager.cancelTransfer(id); + + expect(abortSpy).toHaveBeenCalled(); + }); + + test('persists queue after cancel', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook())!; + transferManager.cancelTransfer(id); + + const stored = localStorage.getItem('readest_transfer_queue'); + expect(stored).toBeTruthy(); + const data = JSON.parse(stored!); + expect(data.transfers[id].status).toBe('cancelled'); + }); + }); + + // ── retryTransfer ──────────────────────────────────────────────── + describe('retryTransfer', () => { + test('resets a failed transfer to pending', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + const id = transferManager.queueUpload(makeBook())!; + useTransferStore.getState().setTransferStatus(id, 'failed', 'Network error'); + + transferManager.retryTransfer(id); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.status).toBe('pending'); + expect(transfer!.error).toBeUndefined(); + }); + }); + + // ── retryAllFailed ─────────────────────────────────────────────── + describe('retryAllFailed', () => { + test('retries all failed transfers', async () => { + const book1 = makeBook({ hash: 'h1', title: 'B1' }); + const book2 = makeBook({ hash: 'h2', title: 'B2' }); + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [book1, book2], + vi.fn(), + translationFn, + ); + + const id1 = transferManager.queueUpload(book1)!; + const id2 = transferManager.queueDownload(book2)!; + useTransferStore.getState().setTransferStatus(id1, 'failed', 'err1'); + useTransferStore.getState().setTransferStatus(id2, 'failed', 'err2'); + + transferManager.retryAllFailed(); + + expect(useTransferStore.getState().transfers[id1]!.status).toBe('pending'); + expect(useTransferStore.getState().transfers[id2]!.status).toBe('pending'); + }); + }); + + // ── pauseQueue / resumeQueue ───────────────────────────────────── + describe('pauseQueue / resumeQueue', () => { + test('pauseQueue pauses the store queue', async () => { + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + + transferManager.pauseQueue(); + expect(useTransferStore.getState().isQueuePaused).toBe(true); + }); + + test('resumeQueue resumes the store queue', async () => { + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + + transferManager.pauseQueue(); + transferManager.resumeQueue(); + expect(useTransferStore.getState().isQueuePaused).toBe(false); + }); + + test('pauseQueue persists state', async () => { + const appService = makeAppService(); + await transferManager.initialize(appService as never, () => [], vi.fn(), translationFn); + + transferManager.pauseQueue(); + const stored = JSON.parse(localStorage.getItem('readest_transfer_queue')!); + expect(stored.isQueuePaused).toBe(true); + }); + }); + + // ── Queue processing (integration-style) ───────────────────────── + describe('queue processing', () => { + test('successful upload calls appService.uploadBook and updates book', async () => { + const book = makeBook({ hash: 'h1', title: 'Test Upload' }); + const appService = makeAppService(); + const updateBook = vi.fn().mockResolvedValue(undefined); + + await transferManager.initialize( + appService as never, + () => [book], + updateBook, + translationFn, + ); + + const id = transferManager.queueUpload(book)!; + + // Let the async queue processing run + await vi.advanceTimersByTimeAsync(500); + + expect(appService['uploadBook']).toHaveBeenCalled(); + expect(updateBook).toHaveBeenCalled(); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.status).toBe('completed'); + }); + + test('successful download calls appService.downloadBook and updates book', async () => { + const book = makeBook({ hash: 'h1', title: 'Test Download' }); + const appService = makeAppService(); + const updateBook = vi.fn().mockResolvedValue(undefined); + + await transferManager.initialize( + appService as never, + () => [book], + updateBook, + translationFn, + ); + + const id = transferManager.queueDownload(book)!; + + await vi.advanceTimersByTimeAsync(500); + + expect(appService['downloadBook']).toHaveBeenCalled(); + expect(updateBook).toHaveBeenCalled(); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.status).toBe('completed'); + }); + + test('successful delete calls appService.deleteBook', async () => { + const book = makeBook({ hash: 'h1', title: 'Test Delete' }); + const appService = makeAppService(); + const updateBook = vi.fn().mockResolvedValue(undefined); + + await transferManager.initialize( + appService as never, + () => [book], + updateBook, + translationFn, + ); + + const id = transferManager.queueDelete(book)!; + + await vi.advanceTimersByTimeAsync(500); + + expect(appService['deleteBook']).toHaveBeenCalled(); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.status).toBe('completed'); + }); + + test('dispatches toast on success for non-background transfers', async () => { + const book = makeBook({ hash: 'h1', title: 'Toast Book' }); + const appService = makeAppService(); + + await transferManager.initialize( + appService as never, + () => [book], + vi.fn().mockResolvedValue(undefined), + translationFn, + ); + + transferManager.queueUpload(book); + await vi.advanceTimersByTimeAsync(500); + + expect(eventDispatcher.dispatch).toHaveBeenCalledWith( + 'toast', + expect.objectContaining({ type: 'info' }), + ); + }); + + test('does not dispatch toast for background transfers', async () => { + const book = makeBook({ hash: 'h1', title: 'BG Book' }); + const appService = makeAppService(); + + await transferManager.initialize( + appService as never, + () => [book], + vi.fn().mockResolvedValue(undefined), + translationFn, + ); + + transferManager.queueDelete(book, 10, true); + await vi.advanceTimersByTimeAsync(500); + + // The toast dispatch should not include an 'info' toast + const calls = (eventDispatcher.dispatch as Mock).mock.calls; + const infoToasts = calls.filter( + (c) => c[0] === 'toast' && (c[1] as Record)?.['type'] === 'info', + ); + expect(infoToasts).toHaveLength(0); + }); + + test('failed transfer with retries schedules retry', async () => { + const book = makeBook({ hash: 'h1', title: 'Retry Book' }); + const appService = makeAppService(); + (appService['uploadBook'] as Mock).mockRejectedValue(new Error('Network fail')); + + await transferManager.initialize( + appService as never, + () => [book], + vi.fn().mockResolvedValue(undefined), + translationFn, + ); + + const id = transferManager.queueUpload(book)!; + await vi.advanceTimersByTimeAsync(500); + + // After first failure, retryCount should be incremented and status back to pending + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer!.retryCount).toBeGreaterThanOrEqual(1); + }); + + test('paused queue does not process transfers', async () => { + const book = makeBook({ hash: 'h1', title: 'Paused Book' }); + const appService = makeAppService(); + + await transferManager.initialize( + appService as never, + () => [book], + vi.fn().mockResolvedValue(undefined), + translationFn, + ); + + transferManager.pauseQueue(); + transferManager.queueUpload(book); + + await vi.advanceTimersByTimeAsync(500); + + // The upload should not have been called because queue is paused + expect(appService['uploadBook']).not.toHaveBeenCalled(); + }); + + test('book not found in library dispatches error', async () => { + const book = makeBook({ hash: 'not-in-lib', title: 'Missing' }); + const appService = makeAppService(); + + await transferManager.initialize( + appService as never, + () => [], // empty library + vi.fn().mockResolvedValue(undefined), + translationFn, + ); + + transferManager.queueUpload(book); + await vi.advanceTimersByTimeAsync(10000); + + // After all retries exhausted, error toast should be dispatched + expect(eventDispatcher.dispatch).toHaveBeenCalledWith( + 'toast', + expect.objectContaining({ type: 'error' }), + ); + }); + }); + + // ── persistQueue / loadPersistedQueue ──────────────────────────── + describe('persistence', () => { + test('persistQueue stores transfers to localStorage', async () => { + const appService = makeAppService(); + await transferManager.initialize( + appService as never, + () => [makeBook()], + vi.fn(), + translationFn, + ); + + transferManager.queueUpload(makeBook()); + + const stored = localStorage.getItem('readest_transfer_queue'); + expect(stored).toBeTruthy(); + const data = JSON.parse(stored!); + expect(Object.keys(data.transfers).length).toBeGreaterThan(0); + }); + + test('handles missing localStorage gracefully', async () => { + // Simulate localStorage being undefined + const originalGetItem = localStorage.getItem; + localStorage.getItem = () => null; + + const appService = makeAppService(); + // Should not throw + await expect( + transferManager.initialize(appService as never, () => [], vi.fn(), translationFn), + ).resolves.not.toThrow(); + + localStorage.getItem = originalGetItem; + }); + + test('handles corrupted localStorage data gracefully', async () => { + localStorage.setItem('readest_transfer_queue', 'invalid-json{{{'); + + const appService = makeAppService(); + // Should not throw + await expect( + transferManager.initialize(appService as never, () => [], vi.fn(), translationFn), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts b/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts new file mode 100644 index 00000000..3961506e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/transformers/transformers.test.ts @@ -0,0 +1,798 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ViewSettings } from '@/types/book'; +import type { TransformContext } from '@/services/transformers/types'; + +// --- Mocks --- + +vi.mock('@/utils/style', () => ({ + transformStylesheet: vi.fn( + (_css: string, _vw: number, _vh: number, _vertical: boolean) => 'transformed-css', + ), +})); + +vi.mock('@/utils/simplecc', () => ({ + initSimpleCC: vi.fn(), + runSimpleCC: vi.fn((text: string, _variant: string) => text), +})); + +vi.mock('@/utils/lang', () => ({ + detectLanguage: vi.fn(() => 'en'), + getLanguageInfo: vi.fn(() => ({ direction: 'ltr' })), + isSameLang: vi.fn(() => true), + isValidLang: vi.fn(() => true), +})); + +vi.mock('@/store/settingsStore', () => ({ + useSettingsStore: { + getState: vi.fn(() => ({ settings: { globalViewSettings: {} } })), + }, +})); + +vi.mock('foliate-js/epubcfi.js', () => ({ + parse: vi.fn(), + toRange: vi.fn(), +})); + +// --- Helpers --- + +function makeCtx(overrides: Partial = {}): TransformContext { + return { + bookKey: 'test-book', + viewSettings: {} as ViewSettings, + userLocale: 'en', + isFixedLayout: false, + content: '', + transformers: [], + ...overrides, + }; +} + +// Suppress console noise +const consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), +}; +void consoleSpy; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ============================================================================= +// footnoteTransformer +// ============================================================================= + +describe('footnoteTransformer', () => { + let footnoteTransformer: typeof import('@/services/transformers/footnote').footnoteTransformer; + + beforeEach(async () => { + ({ footnoteTransformer } = await import('@/services/transformers/footnote')); + }); + + test('has the correct name', () => { + expect(footnoteTransformer.name).toBe('footnote'); + }); + + test('adds epubtype-footnote class to aside with epub:type="footnote"', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + expect(result).toContain('epub:type="footnote"'); + expect(result).toContain('id="fn1"'); + }); + + test('adds epubtype-footnote class to aside with epub:type="endnote"', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + expect(result).toContain('epub:type="endnote"'); + }); + + test('adds epubtype-footnote class to aside with epub:type="note"', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + }); + + test('adds epubtype-footnote class to aside with epub:type="rearnote"', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + expect(result).toContain('epub:type="rearnote"'); + }); + + test('handles multiple aside elements in one document', async () => { + const html = + '' + + '

Text

' + + ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + const matches = result.match(/class="epubtype-footnote"/g); + expect(matches).toHaveLength(2); + }); + + test('does not modify aside without epub:type', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toBe(html); + }); + + test('does not modify aside with non-matching epub:type', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toBe(html); + }); + + test('is case-insensitive for the tag match', async () => { + const html = ''; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + }); + + test('handles single-quoted epub:type values', async () => { + const html = ""; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toContain('class="epubtype-footnote"'); + }); + + test('returns empty string content unchanged', async () => { + const result = await footnoteTransformer.transform(makeCtx({ content: '' })); + expect(result).toBe(''); + }); + + test('preserves content without aside elements', async () => { + const html = '

Hello world

'; + const result = await footnoteTransformer.transform(makeCtx({ content: html })); + expect(result).toBe(html); + }); +}); + +// ============================================================================= +// whitespaceTransformer +// ============================================================================= + +describe('whitespaceTransformer', () => { + let whitespaceTransformer: typeof import('@/services/transformers/whitespace').whitespaceTransformer; + + beforeEach(async () => { + ({ whitespaceTransformer } = await import('@/services/transformers/whitespace')); + }); + + test('has the correct name', () => { + expect(whitespaceTransformer.name).toBe('whitespace'); + }); + + describe('when overrideLayout is true', () => { + const settings = { overrideLayout: true } as ViewSettings; + + test('replaces   with a normal space', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('replaces multiple   entities', async () => { + const html = 'a b c'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('a b c'); + }); + + test('does not replace &nbsp; (escaped ampersand)', async () => { + const html = 'hello&nbsp;world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello&nbsp;world'); + }); + + test('replaces U+00A0 non-breaking space with normal space', async () => { + const html = 'hello\u00A0world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('collapses multiple consecutive spaces into one', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('handles combined   replacement and space collapsing', async () => { + const html = 'hello   world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('handles U+00A0 followed by regular spaces', async () => { + const html = 'hello\u00A0 world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('preserves single spaces', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('returns empty string unchanged', async () => { + const result = await whitespaceTransformer.transform( + makeCtx({ content: '', viewSettings: settings }), + ); + expect(result).toBe(''); + }); + }); + + describe('when overrideLayout is false', () => { + const settings = { overrideLayout: false } as ViewSettings; + + test('returns content unchanged with  ', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + + test('returns content unchanged with U+00A0', async () => { + const html = 'hello\u00A0world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello\u00A0world'); + }); + + test('returns content unchanged with multiple spaces', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe('hello world'); + }); + }); + + describe('when overrideLayout is undefined', () => { + test('returns content unchanged (falsy)', async () => { + const html = 'hello world'; + const result = await whitespaceTransformer.transform(makeCtx({ content: html })); + expect(result).toBe('hello world'); + }); + }); +}); + +// ============================================================================= +// punctuationTransformer +// ============================================================================= + +describe('punctuationTransformer', () => { + let punctuationTransformer: typeof import('@/services/transformers/punctuation').punctuationTransformer; + + beforeEach(async () => { + ({ punctuationTransformer } = await import('@/services/transformers/punctuation')); + }); + + test('has the correct name', () => { + expect(punctuationTransformer.name).toBe('punctuation'); + }); + + describe('when replaceQuotationMarks is false', () => { + test('returns content unchanged', async () => { + const html = '\u201C\u4F60\u597D\u201D'; // "你好" + const result = await punctuationTransformer.transform( + makeCtx({ + content: html, + viewSettings: { replaceQuotationMarks: false } as ViewSettings, + }), + ); + expect(result).toBe(html); + }); + }); + + describe('when replaceQuotationMarks is true, no variant conversion', () => { + const baseSettings = { + replaceQuotationMarks: true, + convertChineseVariant: 'none' as const, + vertical: false, + } as ViewSettings; + + test('returns content unchanged when no variant or vertical conversion needed', async () => { + const html = '\u201C\u4F60\u597D\u201D'; + const result = await punctuationTransformer.transform( + makeCtx({ content: html, viewSettings: baseSettings }), + ); + expect(result).toBe(html); + }); + }); + + describe('Chinese variant quotation mark conversion (Hans to Hant)', () => { + const settings = { + replaceQuotationMarks: true, + convertChineseVariant: 's2t' as const, + vertical: false, + } as ViewSettings; + + test('converts left double curly quote to left corner bracket', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u201C', viewSettings: settings }), + ); + // "\u201C" -> "\u300C" (「) + expect(result).toBe('\u300C'); + }); + + test('converts right double curly quote to right corner bracket', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u201D', viewSettings: settings }), + ); + // "\u201D" -> "\u300D" (」) + expect(result).toBe('\u300D'); + }); + + test('converts left single curly quote to left double corner bracket', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u2018', viewSettings: settings }), + ); + // "\u2018" -> "\u300E" (『) + expect(result).toBe('\u300E'); + }); + + test('converts right single curly quote to right double corner bracket', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u2019', viewSettings: settings }), + ); + // "\u2019" -> "\u300F" (』) + expect(result).toBe('\u300F'); + }); + + test('converts mixed quotation marks in a sentence', async () => { + const html = '\u201C\u4F60\u597D\u201D\u4ED6\u8BF4\u2018\u518D\u89C1\u2019'; + const result = await punctuationTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toContain('\u300C'); + expect(result).toContain('\u300D'); + expect(result).toContain('\u300E'); + expect(result).toContain('\u300F'); + }); + }); + + describe('reverse punctuation transform with variant conversion', () => { + const settings = { + replaceQuotationMarks: true, + convertChineseVariant: 's2t' as const, + vertical: false, + } as ViewSettings; + + test('reverses the conversion direction when reversePunctuationTransform is true', async () => { + // With s2t and reverse=true, the shouldReverse flag flips, so the mapping + // goes from Hant -> Hans instead of Hans -> Hant + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\u300C\u300D', + viewSettings: settings, + reversePunctuationTransform: true, + }), + ); + // 「」 -> "" + expect(result).toContain('\u201C'); + expect(result).toContain('\u201D'); + }); + }); + + describe('vertical quotation mark conversion', () => { + const settings = { + replaceQuotationMarks: true, + convertChineseVariant: 'none' as const, + vertical: true, + } as ViewSettings; + + test('converts left double curly quote to vertical form (Hans default)', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u201C', viewSettings: settings }), + ); + // Hans vertical: "\u201C" -> "\uFE43" (﹃) + expect(result).toBe('\uFE43'); + }); + + test('converts right double curly quote to vertical form (Hans default)', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u201D', viewSettings: settings }), + ); + // Hans vertical: "\u201D" -> "\uFE44" (﹄) + expect(result).toBe('\uFE44'); + }); + + test('converts left single curly quote to vertical form (Hans default)', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u2018', viewSettings: settings }), + ); + // Hans vertical: "\u2018" -> "\uFE41" (﹁) + expect(result).toBe('\uFE41'); + }); + + test('converts right single curly quote to vertical form (Hans default)', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u2019', viewSettings: settings }), + ); + // Hans vertical: "\u2019" -> "\uFE42" (﹂) + expect(result).toBe('\uFE42'); + }); + + test('uses Hant vertical map when primaryLanguage is zh-Hant', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\u201C', + viewSettings: settings, + primaryLanguage: 'zh-Hant', + }), + ); + // Hant vertical: "\u201C" -> "\uFE41" (﹁) — different from Hans + expect(result).toBe('\uFE41'); + }); + + test('uses Hant vertical map when primaryLanguage is zh-TW', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\u201C', + viewSettings: settings, + primaryLanguage: 'zh-TW', + }), + ); + expect(result).toBe('\uFE41'); + }); + + test('uses Hant vertical map when userLocale is zh-TW', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\u201C', + viewSettings: settings, + userLocale: 'zh-TW', + }), + ); + expect(result).toBe('\uFE41'); + }); + + test('uses Hant vertical map when userLocale is zh_TW', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\u201C', + viewSettings: settings, + userLocale: 'zh_TW', + }), + ); + expect(result).toBe('\uFE41'); + }); + + test('reverses vertical conversion when reversePunctuationTransform is true', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '\uFE43', + viewSettings: settings, + reversePunctuationTransform: true, + }), + ); + // Hans reverse vertical: "\uFE43" (﹃) -> "\u201C" (") + expect(result).toBe('\u201C'); + }); + }); + + describe('combined variant and vertical conversion', () => { + const settings = { + replaceQuotationMarks: true, + convertChineseVariant: 's2t' as const, + vertical: true, + } as ViewSettings; + + test('applies variant conversion first, then vertical conversion', async () => { + // s2t converts "\u201C" -> "\u300C" (「), then vertical Hans converts "\u300C" -> "\uFE41" (﹁) + const result = await punctuationTransformer.transform( + makeCtx({ content: '\u201C', viewSettings: settings }), + ); + expect(result).toBe('\uFE41'); + }); + }); + + test('returns empty string unchanged', async () => { + const result = await punctuationTransformer.transform( + makeCtx({ + content: '', + viewSettings: { replaceQuotationMarks: true, vertical: true } as ViewSettings, + }), + ); + expect(result).toBe(''); + }); +}); + +// ============================================================================= +// styleTransformer +// ============================================================================= + +describe('styleTransformer', () => { + let styleTransformer: typeof import('@/services/transformers/style').styleTransformer; + let transformStylesheet: ReturnType; + + beforeEach(async () => { + const styleMod = await import('@/utils/style'); + transformStylesheet = styleMod.transformStylesheet as ReturnType; + transformStylesheet.mockClear(); + transformStylesheet.mockResolvedValue('transformed-css'); + ({ styleTransformer } = await import('@/services/transformers/style')); + }); + + test('has the correct name', () => { + expect(styleTransformer.name).toBe('style'); + }); + + test('returns content unchanged for fixed layout', async () => { + const html = ''; + const result = await styleTransformer.transform( + makeCtx({ content: html, isFixedLayout: true }), + ); + expect(result).toBe(html); + expect(transformStylesheet).not.toHaveBeenCalled(); + }); + + test('transforms style blocks for non-fixed layout', async () => { + const html = ''; + const result = await styleTransformer.transform( + makeCtx({ content: html, width: 800, height: 600 }), + ); + expect(transformStylesheet).toHaveBeenCalledWith('body { color: red; }', 800, 600, undefined); + expect(result).toContain(''); + }); + + test('transforms multiple style blocks', async () => { + transformStylesheet.mockResolvedValueOnce('css-1').mockResolvedValueOnce('css-2'); + const html = + '' + + '' + + '' + + ''; + const result = await styleTransformer.transform( + makeCtx({ content: html, width: 1024, height: 768 }), + ); + expect(transformStylesheet).toHaveBeenCalledTimes(2); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + test('passes vertical setting to transformStylesheet', async () => { + const html = ''; + const settings = { vertical: true } as ViewSettings; + await styleTransformer.transform( + makeCtx({ content: html, viewSettings: settings, width: 800, height: 600 }), + ); + expect(transformStylesheet).toHaveBeenCalledWith(expect.any(String), 800, 600, true); + }); + + test('uses window dimensions when width/height not provided', async () => { + const html = ''; + await styleTransformer.transform(makeCtx({ content: html })); + expect(transformStylesheet).toHaveBeenCalledWith( + expect.any(String), + window.innerWidth, + window.innerHeight, + undefined, + ); + }); + + test('returns content unchanged when no style blocks present', async () => { + const html = '

Hello

'; + const result = await styleTransformer.transform(makeCtx({ content: html })); + expect(result).toBe(html); + expect(transformStylesheet).not.toHaveBeenCalled(); + }); + + test('returns empty string unchanged', async () => { + const result = await styleTransformer.transform(makeCtx({ content: '' })); + expect(result).toBe(''); + expect(transformStylesheet).not.toHaveBeenCalled(); + }); +}); + +// ============================================================================= +// sanitizerTransformer +// ============================================================================= + +describe('sanitizerTransformer', () => { + let sanitizerTransformer: typeof import('@/services/transformers/sanitizer').sanitizerTransformer; + + beforeEach(async () => { + ({ sanitizerTransformer } = await import('@/services/transformers/sanitizer')); + }); + + test('has the correct name', () => { + expect(sanitizerTransformer.name).toBe('sanitizer'); + }); + + test('returns content unchanged when allowScript is true', async () => { + const html = '

Hello

'; + const settings = { allowScript: true } as ViewSettings; + const result = await sanitizerTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).toBe(html); + }); + + test('sanitizes content when allowScript is false', async () => { + const html = '

Hello

'; + const settings = { allowScript: false } as ViewSettings; + const result = await sanitizerTransformer.transform( + makeCtx({ content: html, viewSettings: settings }), + ); + expect(result).not.toContain('

Safe

'; + const result = await sanitizerTransformer.transform(makeCtx({ content: html })); + expect(result).not.toContain('