Files
readest/apps/readest-app/src/__tests__/utils/replacement.test.ts
T
Qianxue Ge 54fdf5f1fd feat(replacement): text replacement feature for EPUB books (#2725)
* add: basic ui replacement menu

* feat(replacement): modified ViewSettings interface and added Replacement type

* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend

* add: delete global option and click gear option to get rid of menu

* docs: add test cases for replacementoptions file

* edits to enable readest to build

* basic changes for rule types

* replacement transformer file added

* additional support code added

* interim updates to replacement.ts file

* adding console log statements to confirm functionality without frontend

* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.

* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol

* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)

* test suite built, and fully passing. made consle log edits too.

* added more replacement rules, but figuring out why they arent being implemented by my code.

* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.

* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions

- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components

* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component

- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm

* fix(build): Add ReplacementMenu placeholder component

- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules

* test(replacement): Add comprehensive tests for ReplacementOptions and word limit

ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options

wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)

* refactor: removed unused initial definition of Replacement

* feat: added replacement rules window in bookmenu

* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked

* feat: added Replacement tab in SettingsDialog, displays global rules

* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.

* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.

* test: add integration tests for text replacement functionality

* fix: added single rules section to ReplacementRulesWindow

* fix(replacement): added null checks to some unsafe calls in integration tests

* fix(replacements): added non-null assertion operator for a previously initialized variable

* refactor: created ReplacementPanel and edited style of inputs

* feat: disable the edit feature for selected phrase

* refactor: use toast instead of banner for confirmation msg

* feat: automatically reload the page to apply changes

* feat: disable global rule for book if deleted in book view

* fix(replacement): Improve popup positioning and eliminate ghost animation

- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation

* fix: implement single-instance replacement with persistence

- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival

Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes

* fix: allow multiple single-instance replacements for same word

Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.

The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.

* fix: prevent cascading replacements and add wholeWord support

- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules

* Fix whole-word matching for replacement rules

- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues

* test: added rAF in setup to for ReplacementOptions tests

* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases

* refactor: refactored replacement logic for case sensitivity and word boundaries

* test: added tests for scope precedence and case sensitivity across scopes

* refactor: removed unnecessary code from testing

* feat: able to display, edit, and delete single-instance rules in book settings

* fix: connected case sensitive checkbox to backend, fixed merge and delete logic

* test: updated test cases to reflect changes on case sensitivity and rules rendering

* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge

* fix: add logic for grayed out button for non-epubs

* chore: update foliate-js submodule from upstream merge

* fix: resolve all TypeScript/ESLint linting errors

- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx

* fix: add localStorage mock to vitest setup

- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment

* fix: resolve ESLint and TypeScript build errors

- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully

* fix: remove lookbehind regex for browser compatibility

- Replace lookbehind assertions (?<!...) with manual boundary checking
- Add isUnicodeWordChar helper function for manual Unicode word boundary detection
- Apply manual boundary checks in applyMultiReplacement and applySingleInstance
- Fixes build_web_app check failures by avoiding lookbehind in compiled output
- Maintains whole-word matching functionality for both ASCII and Unicode patterns

* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error

- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version

* fix: use local tauri path directly to resolve version conflicts

- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies

* fix: use version requirement with patch for tauri dependency

- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
  all tauri dependencies (direct and transitive) use the patched version

* fix: remove plugin patches that cause resolution errors

- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve

* fix: update tauri submodule with tauri-utils version fixes

* fix: revert tauri submodule and update tauri-utils to 2.8.0

- Revert submodule changes that can't be pushed to remote
- Update local tauri-utils version to 2.8.0 to match other packages
- This avoids the need to modify the submodule

* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error

- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error

* chore: restore Cargo.lock from upstream

- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change

* fix: resolve TypeScript errors in test files

- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates

* fix: prevent race condition when deleting replacement rules rapidly

- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error

* fix: allow phrases and lines with quotes for single-instance replacements

- Updated isWholeWord() to allow phrases (text with spaces or punctuation)
- Phrases are always allowed for single-instance replacements
- Only single words are checked for partial word matches
- Fixes issue where lines with quotes couldn't be replaced
- Added detailed logging for debugging phrase detection

* fix: allow selections with boundary punctuation and fix pattern matching for punctuation

- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly

* fix: escape HTML entities in replacement text to preserve angle brackets

- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly

* fix: revert Tauri backend changes and resolve package.json conflict

- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation

* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6

* removed useless tests and backend tests from ReplacementOptions integration testing suite

* chore: revert foliate-js submodule to match readest/readest main

* fix: refactored wordLimit logic into a separate util file

* fix: removed additional pr description

* refactor: rewrite replacement transformer to use DOM-based approach
replace string manipulation with DOMParser and TreeWalker
follow pattern from simpleecc transformer

* style: format code with prettier

* fix: remove unused string-manipulation functions

* fix: refactored display dialog logic to match other dialogs

* fix: enabled global rule deletion in book menu

* fix: removed ReplacementPanel from library settings

* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings

* fix: removed text replacement tab from settings dialog

* fix: applied prettier code formatter to replacement rules window

* chore: fix formatting and remove unused file listed by chrox

* chore: format all changed files from pr 2693 and revert pnpm-lock

* rebased Cargo.lock, package.json, pnpm-lock.yaml to upstream main
edits to enable readest to build

* basic changes for rule types

* replacement transformer file added

* additional support code added

* interim updates to replacement.ts file

* adding console log statements to confirm functionality without frontend

* adding more console logs for debugging; i think i got my replacement working, will clean console logs and add actual tests now.

* figured out how to get my transformer to work. replacement doesnt actually work yet. figuring that out rn. committing before i destroy something, lol

* replcement logic working with hard coded tests. code is cleaned up with minimal console logs. actual replacement logic + testing is next :)

* test suite built, and fully passing. made consle log edits too.

* added more replacement rules, but figuring out why they arent being implemented by my code.

* cleaning up test suite to not break when there are 0 rules; test is commited with 1 local rule. not sure if that rule is going to copy over when i merge.

* add: basic ui replacement menu

* add: frontend menu ui to annotation settings
- create replacementoptions file for 4 fix options: fix once, fix in library, fix in book, fix in library
-integrate with annotator.tsx
only frontend changes, but initialzied in backend

* add: delete global option and click gear option to get rid of menu

* docs: add test cases for replacementoptions file

* feat(replacement): modified ViewSettings interface and added Replacement type

feat(replacement): modified viewsettings interface and added ReplacementRulesConfig

* feat(replacement): Add text field, case sensitivity checkbox, and confirmation dialog to ReplacementOptions

- Add text input field for replacement text with placeholder
- Add 'Case Sensitive' checkbox (default: unchecked/case-insensitive)
- Implement two-step confirmation flow with Back/Confirm buttons
- Show preview of original text, replacement text, scope, and case sensitivity
- Disable scope buttons until replacement text is entered
- Display truncated preview for long selected text (>50 chars)
- Export ReplacementConfig type for use in parent components

* feat(replacement): Add 30-word limit and integrate new ReplacementOptions component

- Add MAX_REPLACEMENT_WORDS constant (30 words)
- Add getWordCount() utility function for word counting
- Show warning toast when word limit exceeded on Text Replacement click
- Replace old fix handlers with single handleReplacementConfirm()
- Integrate with new ReplacementConfig (replacementText, caseSensitive, scope)
- Display success toast with scope and case sensitivity info on confirm

* fix(build): Add ReplacementMenu placeholder component

- Create placeholder component to fix missing import error in reader/page.tsx
- Component returns null for now, to be implemented with global replacement rules

* test(replacement): Add comprehensive tests for ReplacementOptions and word limit

ReplacementOptions.test.tsx:
- Test rendering of text input, checkbox, and scope buttons
- Test case sensitivity checkbox toggle and state
- Test disabled buttons when no replacement text entered
- Test confirmation dialog flow and Back/Confirm buttons
- Test click outside and Cancel button behavior
- Test full replacement flow with all options

wordLimit.test.ts:
- Test word counting with various inputs (spaces, newlines, unicode)
- Test 30-word limit boundary conditions
- Test case-sensitive vs case-insensitive matching logic
- Test edge cases (empty string, long words, punctuation)

* refactor: removed unused initial definition of Replacement

* feat: added replacement rules window in bookmenu

* test: added tests to verify the replacement rules window renders book and global replacement rules, and it opens when bookmenu item is clicked

* feat: added Replacement tab in SettingsDialog, displays global rules

* fix: added single rules section to ReplacementRulesWindow

* refactor: created ReplacementPanel and edited style of inputs

* feat(replacement): connected front-end to functions. todo: fix the automatic reload functionality.

* fix(replacement): simplified re-rendering logic, doesn't fail on epubs anymore.

* test: add integration tests for text replacement functionality

* fix(replacement): added null checks to some unsafe calls in integration tests

* fix(replacements): added non-null assertion operator for a previously initialized variable

* feat: disable the edit feature for selected phrase

* refactor: use toast instead of banner for confirmation msg

* feat: automatically reload the page to apply changes

* feat: disable global rule for book if deleted in book view

* fix(replacement): Improve popup positioning and eliminate ghost animation

- Add viewport boundary detection to keep popup within visible area
- Calculate position only once on mount to prevent jumping when other UI appears
- Use visibility: hidden until position is calculated to eliminate ghost animation
- Add max-height with overflow-y: auto for scrollable content
- Popup now appears directly in correct position without two-step animation

* fix: only allow replacement for epubs, remove replacement rendering for non-epubs, add test cases

* fix: add logic for grayed out button for non-epubs

* fix: resolve all TypeScript/ESLint linting errors

- Fix prefer-const error in ReplacementOptions.tsx
- Fix set-state-in-effect error in ReplacementRulesWindow.tsx (use lazy initializer)
- Replace all @typescript-eslint/no-explicit-any with proper types (ReplacementRule, unknown, etc.)
- Fix unused error variables in replacement.ts (prefix with _)
- Remove unused eslint-disable directives
- Add missing ReplacementRule import in ReplacementPanel.tsx

* fix: add localStorage mock to vitest setup

- Fixes test failures in ReplacementRulesWindow and SettingsDialog tests
- localStorage mock ensures all Storage API methods are available in test environment

* fix: implement single-instance replacement with persistence

- Add sectionHref to TransformContext for section tracking
- Add singleInstance, sectionHref, occurrenceIndex fields to ReplacementRule
- Pass section name from FoliateViewer to transformer context
- Switch transformer from DOM-based to string-based replacement
- Handle single-instance rules with section matching and occurrence tracking
- Update Annotator to track occurrence index and apply direct DOM changes
- Persist single-instance rules for refresh survival

Single-instance replacements now:
1. Apply immediately via direct DOM modification
2. Store occurrence index and section for precise targeting
3. Persist across page refreshes

* fix: allow multiple single-instance replacements for same word

Single-instance rules now always create new entries instead of merging.
This fixes the issue where replacing multiple occurrences of the same
word would overwrite previous rules.

The transformer applies rules in sequence, so each rule targets
occurrence index 0 of the current (modified) string, allowing
cascading replacements to work correctly after refresh.

* fix: prevent cascading replacements and add wholeWord support

- Add wholeWord field to ReplacementRule for word boundary matching
- Track replaced regions to prevent replacement text from being re-matched
- Fix cascading replacement issue where replacement text was matched again
- Apply replacements from right to left to preserve positions
- Support whole word matching with \b boundaries for both single-instance and regular rules

* Fix whole-word matching for replacement rules

- Auto-enforce whole-word matching for simple word patterns (letters only)
- Add HTML tag boundary checks to prevent matching across tags
- Add double-check validation for whole-word matches
- Prevent matching 'and' inside words like 'England', 'stand', 'understand'
- Add comprehensive logging for debugging replacement issues

* refactor: refactored replacement logic for case sensitivity and word boundaries

* test: added tests for scope precedence and case sensitivity across scopes

* refactor: removed unnecessary code from testing

* feat: able to display, edit, and delete single-instance rules in book settings

* fix: connected case sensitive checkbox to backend, fixed merge and delete logic

* test: updated test cases to reflect changes on case sensitivity and rules rendering

* test: modified ReplacementOptions test to remove unnecessary case sensitive check from merge

* fix: resolve ESLint and TypeScript build errors

- Fix all remaining @typescript-eslint/no-explicit-any errors in test files
- Fix unused error variables in replacement.ts (prefix with _)
- Fix TypeScript error in ReplacementRulesWindow.tsx (move @ts-ignore to correct location)
- All ESLint checks now pass
- Web and Tauri builds compile successfully

* fix: update tauri-utils version to 2.8.1 to resolve duplicate symbol error

- Update local tauri-utils version from 2.8.0 to 2.8.1 to match crates.io version
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linker error
- Ensures all dependencies use the same tauri-utils version

* fix: use local tauri path directly to resolve version conflicts

- Change tauri dependency to use local path instead of version requirement
- This ensures all dependencies use the same local tauri version (2.9.3)
- Fixes 'links = Tauri' conflict error in Rust linting
- The patch.crates-io should still work for transitive dependencies

* fix: use version requirement with patch for tauri dependency

- Revert to using version requirement '2' instead of direct path
- Rely on [patch.crates-io] to use local tauri version
- Remove Cargo.lock to force fresh dependency resolution
- This should resolve the 'links = Tauri' conflict by ensuring
  all tauri dependencies (direct and transitive) use the patched version

* fix: remove plugin patches that cause resolution errors

- Remove all tauri-plugin git patches from [patch.crates-io]
- Keep only tauri, tauri-utils, and tauri-build patches
- Plugins from crates.io will use the patched tauri via transitive dependencies
- Fixes error: patch for tauri-plugin-oauth failed to resolve

* fix: add tauri-plugin to workspace and patch to resolve duplicate symbol error

- Add packages/tauri/crates/tauri-plugin to workspace members
- Add tauri-plugin patch to [patch.crates-io]
- This ensures all tauri dependencies use local versions
- Fixes duplicate symbol __TAURI_BUNDLE_TYPE linking error

* chore: restore Cargo.lock from upstream

- Restore the original Cargo.lock from readest/readest main branch
- This ensures reproducible builds and matches upstream
- The lock file will be updated by cargo when dependencies change

* fix: resolve TypeScript errors in test files

- Fix ReplacementOptions.test.tsx: add optional chaining for possibly undefined values
- Fix ReplacementRulesWindow.test.tsx: use proper type assertions for store setState calls
- Use (store.setState as unknown as (state: unknown) => void) pattern for partial state updates

* fix: allow selections with boundary punctuation and fix pattern matching for punctuation

- Updated isWholeWord() to explicitly allow selections that start or end with punctuation (e.g., 'tis, off;, look,)
- Fixed normalizePattern() to handle patterns with leading/trailing punctuation correctly
- Word boundaries are now only added around the word part, not the punctuation
- Fixes issue where replacements like 'scholar;' were not matching correctly

* fix: prevent race condition when deleting replacement rules rapidly

- Add isReloading state to track ongoing delete/edit operations
- Prevent multiple rapid deletions that cause runtime errors during page reload
- Show warning toast when user tries to delete while reload is in progress
- Add finally blocks to ensure isReloading is always reset
- This prevents the 'book doesn't finish rerendering' error

* fix: escape HTML entities in replacement text to preserve angle brackets

- Added escapeHtmlEntities() function to escape HTML special characters
- Apply HTML escaping to replacement text in both multi and single-instance replacements
- Fixes issue where replacement text like '<<AND>>' was being interpreted as HTML tags
- Angle brackets and other HTML entities are now properly escaped and displayed correctly

* fix: revert Tauri backend changes and resolve package.json conflict

- Revert Cargo.toml and src-tauri/Cargo.toml to match upstream/main
- Resolve @tauri-apps/cli version conflict (2.9.5 -> 2.9.6)
- These changes are not related to the replacement feature implementation

* fix: update pnpm-lock.yaml to match @tauri-apps/cli 2.9.6

* removed useless tests and backend tests from ReplacementOptions integration testing suite

* chore: revert foliate-js submodule to match readest/readest main

* fix: refactored display dialog logic to match other dialogs

* fix: enabled global rule deletion in book menu

* fix: removed ReplacementPanel from library settings

* fix: deleted SettingsDialog.replacement.test.tsx since we no longer need to display replacements in library settings

* fix: removed text replacement tab from settings dialog

* fix: applied prettier code formatter to replacement rules window

* fix: refactored wordLimit logic into a separate util file

* fix: removed additional pr description

* style: format code with prettier

* chore: fix formatting and remove unused file listed by chrox

* chore: format all changed files from pr 2693 and revert pnpm-lock

* fix: fixed inconsistencies from rebase

* refactor: removed unused code

* refactor: removed unintentional formatting changes

* fix: set upstream for packages/tauri-plugins to the readest branch

* fix: used original Cargo.lock file

* fix: got Cargo.lock from upstream

* fix: fetched SettingsDialog from upstream main

* fix: pointed tauri-plugins to the same commit as upstream

* chore: remove unnecssary comments from replacement.ts

* chore: fixed more unnecessary comments

---------

Co-authored-by: fatbiscuit247 <fatbiscuit247@github.com>
Co-authored-by: joon <your.email@example.com>
Co-authored-by: jarchenn <jerryc2@andrew.cmu.edu>
Co-authored-by: joon0429 <68578999+joon0429@users.noreply.github.com>
Co-authored-by: Jerry Chen <50bmg@Jerrys-MacBook-Pro-9.local>
Co-authored-by: Alicia Chen <aliciach@andrew.cmu.edu>
Co-authored-by: Jerry Chen <50bmg@MacBook-Pro-7.local>
Co-authored-by: Jerry Chen <50bmg@macbook-pro-158.wifi.local.cmu.edu>
Co-authored-by: fatbiscuit247 <136537548+fatbiscuit247@users.noreply.github.com>
2025-12-17 10:06:59 +08:00

1156 lines
34 KiB
TypeScript

import { describe, test, expect, vi, afterEach } from 'vitest';
// MUST BE FIRST — before imports
vi.mock('@/services/translators/cache', () => ({
initCache: vi.fn(),
getCachedTranslation: vi.fn(() => null),
saveToCache: vi.fn(),
pruneCache: vi.fn(),
}));
vi.mock('@/store/settingsStore', () => {
const mockState = {
settings: {
globalViewSettings: { replacementRules: [] },
globalReadSettings: {},
kosync: { enabled: false },
},
setSettings: vi.fn(),
saveSettings: vi.fn(),
};
const fn = vi.fn(() => mockState) as unknown as {
(): typeof mockState;
getState: () => typeof mockState;
setState: (partial: Partial<typeof mockState>) => void;
subscribe: (listener: () => void) => () => void;
destroy: () => void;
};
fn.getState = () => mockState;
fn.setState = vi.fn();
fn.subscribe = vi.fn();
fn.destroy = vi.fn();
return { useSettingsStore: fn };
});
vi.mock('@/store/readerStore', () => {
const mockState = {
getViewSettings: () => ({ replacementRules: [] }),
setViewSettings: vi.fn(),
};
const fn = vi.fn(() => mockState) as unknown as {
(): typeof mockState;
getState: () => typeof mockState;
setState: (partial: Partial<typeof mockState>) => void;
subscribe: (listener: () => void) => () => void;
destroy: () => void;
};
fn.getState = () => mockState;
fn.setState = vi.fn();
fn.subscribe = vi.fn();
fn.destroy = vi.fn();
return { useReaderStore: fn };
});
vi.mock('@/store/bookDataStore', () => {
const mockState = {
getConfig: () => ({}),
saveConfig: vi.fn(),
};
const fn = vi.fn(() => mockState) as unknown as {
(): typeof mockState;
getState: () => typeof mockState;
setState: (partial: Partial<typeof mockState>) => void;
subscribe: (listener: () => void) => () => void;
destroy: () => void;
};
fn.getState = () => mockState;
fn.setState = vi.fn();
fn.subscribe = vi.fn();
fn.destroy = vi.fn();
return { useBookDataStore: fn };
});
import { replacementTransformer } from '@/services/transformers/replacement';
import { TransformContext } from '@/services/transformers/types';
import { ViewSettings, ReplacementRule } from '@/types/book';
import {
createReplacementRule,
mergeReplacementRules,
validateReplacementRulePattern,
} from '@/services/transformers/replacement';
describe('replacementTransformer', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const createMockContext = (
rules: ReplacementRule[] | undefined,
content: string,
): TransformContext => {
const viewSettings = {
replacementRules: rules,
} as Partial<ViewSettings> as ViewSettings;
return {
bookKey: 'test-book',
viewSettings,
userLocale: 'en',
content,
transformers: ['replacement'],
};
};
describe('basic functionality', () => {
test('should return content unchanged when no rules', async () => {
const ctx = createMockContext(undefined, '<p>Hello world</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Hello world');
});
test('should return content unchanged when rules array is empty', async () => {
const ctx = createMockContext([], '<p>Hello world</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Hello world');
});
test('should apply simple string replacement', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'Hello',
replacement: 'Hi',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Hi world');
expect(result).not.toContain('Hello');
});
test('should apply multiple simple replacements', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'cat',
replacement: 'dog',
enabled: true,
isRegex: false,
order: 1,
},
{
id: '2',
pattern: 'The',
replacement: 'A',
enabled: true,
isRegex: false,
order: 2,
},
];
const ctx = createMockContext(rules, '<p>The cat sat</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('A dog sat');
});
test('should replace all occurrences, not just first', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'the',
replacement: 'THE',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>the cat and the dog</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('THE cat and THE dog');
});
});
describe('regex functionality', () => {
test('should apply regex replacement', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: '\\d+',
replacement: 'NUMBER',
enabled: true,
isRegex: true,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>I have 5 apples and 10 oranges</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('NUMBER');
// Check that numbers in the body content are replaced (not in XML namespace URLs)
const parser = new DOMParser();
const doc = parser.parseFromString(result, 'text/html');
const bodyText = doc.body?.textContent || '';
expect(bodyText).not.toMatch(/\d+/);
expect(bodyText).toContain('NUMBER');
});
test('should handle regex with word boundaries', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: '\\bcat\\b',
replacement: 'dog',
enabled: true,
isRegex: true,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>The cat sat on the category</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('dog');
expect(result).toContain('category'); // Should not replace "cat" in "category"
});
test('should handle case-insensitive regex when specified', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'the',
replacement: 'THE',
enabled: true,
isRegex: true,
order: 1,
caseSensitive: true,
},
];
const ctx = createMockContext(rules, '<p>The cat and the dog</p>');
const result = await replacementTransformer.transform(ctx);
// Note: Our implementation uses 'g' flag, so it will match "the" but not "The"
// This is expected behavior - regex is case-sensitive by default
expect(result).toContain('THE');
expect(result).toContain('The cat'); // uppercase "The" stays untouched
});
});
describe('case sensitivity (single instance)', () => {
test('should be case-sensitive by default for single instance', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: true,
occurrenceIndex: 0,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world hello there</p>');
const result = await replacementTransformer.transform(ctx);
// "hello" (lowercase) should match, "Hello" should not
expect(result).toContain('Hello world hi there');
expect(result).not.toContain('hi world hi there');
});
test('should replace case-sensitive match at correct occurrence', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: true,
occurrenceIndex: 1, // second occurrence
order: 1,
},
];
const ctx = createMockContext(rules, '<p>hello world and hello again</p>');
const result = await replacementTransformer.transform(ctx);
// Only second "hello" should be replaced
expect(result).toContain('hello world and hi again');
const helloCount = (result.match(/hello/g) || []).length;
const hiCount = (result.match(/hi/g) || []).length;
expect(helloCount).toBe(1);
expect(hiCount).toBe(1);
});
});
describe('case sensitivity (book scope)', () => {
test('should be case-sensitive by default for book scope', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world hello there Hello</p>');
const result = await replacementTransformer.transform(ctx);
// Only lowercase "hello" should match
expect(result).toContain('Hello world hi there Hello');
const helloCount = (result.match(/[Hh]ello/g) || []).length;
const hiCount = (result.match(/hi/g) || []).length;
expect(helloCount).toBe(2); // Two "Hello" remain
expect(hiCount).toBe(1); // One "hello" replaced
});
test('should replace all case-sensitive matches in book scope', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'world',
replacement: 'universe',
enabled: true,
isRegex: false,
singleInstance: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>world and world and World</p>');
const result = await replacementTransformer.transform(ctx);
// Both lowercase "world" should match, "World" should not
expect(result).toContain('universe and universe and World');
const worldCount = (result.match(/[Ww]orld/g) || []).length;
const universeCount = (result.match(/universe/g) || []).length;
expect(worldCount).toBe(1); // One "World" remains
expect(universeCount).toBe(2);
});
});
describe('case sensitivity (global scope)', () => {
test('should be case-sensitive by default for global scope', async () => {
// Simulate global rules via the merged rules mechanism
const globalRules: ReplacementRule[] = [
{
id: 'global-1',
pattern: 'book',
replacement: 'tome',
enabled: true,
isRegex: false,
singleInstance: false,
global: true,
order: 1,
},
];
const ctx = createMockContext(globalRules, '<p>book and Book and BOOK</p>');
const result = await replacementTransformer.transform(ctx);
// Only lowercase "book" should match
expect(result).toContain('tome and Book and BOOK');
const bookCount = (result.match(/[Bb]ook|BOOK/g) || []).length;
const tomeCount = (result.match(/tome/g) || []).length;
expect(bookCount).toBe(2); // "Book" and "BOOK" remain
expect(tomeCount).toBe(1);
});
test('should replace all case-sensitive matches across global scope', async () => {
const globalRules: ReplacementRule[] = [
{
id: 'global-1',
pattern: 'test',
replacement: 'exam',
enabled: true,
isRegex: false,
singleInstance: false,
global: true,
order: 1,
},
];
const ctx = createMockContext(globalRules, '<p>test and test and Test and TEST</p>');
const result = await replacementTransformer.transform(ctx);
// Only lowercase "test" should match
expect(result).toContain('exam and exam and Test and TEST');
const testCount = (result.match(/[Tt]est|TEST/g) || []).length;
const examCount = (result.match(/exam/g) || []).length;
expect(testCount).toBe(2); // "Test" and "TEST" remain
expect(examCount).toBe(2);
});
});
describe('case sensitivity toggle (single instance)', () => {
test('should respect case-sensitive toggle for single instance', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: true,
occurrenceIndex: 0,
caseSensitive: true, // Explicitly case-sensitive
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world hello there</p>');
const result = await replacementTransformer.transform(ctx);
// Only exact case match should be replaced
expect(result).toContain('Hello world hi there');
});
test('should respect case-insensitive toggle for single instance (explicit)', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: true,
occurrenceIndex: 0,
caseSensitive: false, // Case-insensitive but will be ignored
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world hello there</p>');
const result = await replacementTransformer.transform(ctx);
// First match should be replaced, case sensitve is set to true by default for single instance
expect(result).toContain('Hello world hi there');
const hiCount = (result.match(/hi/g) || []).length;
expect(hiCount).toBe(1);
});
});
describe('case sensitivity toggle (book scope)', () => {
test('should replace case-sensitive when flag is true', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'test',
replacement: 'exam',
enabled: true,
isRegex: false,
singleInstance: false,
caseSensitive: true, // Case-sensitive
order: 1,
},
];
const ctx = createMockContext(rules, '<p>test Test TEST</p>');
const result = await replacementTransformer.transform(ctx);
// Only lowercase "test" should be replaced
expect(result).toContain('exam Test TEST');
});
test('should replace case-insensitive when flag is false (explicit)', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'test',
replacement: 'exam',
enabled: true,
isRegex: false,
singleInstance: false,
caseSensitive: false, // Case-insensitive (explicitly set)
order: 1,
},
];
const ctx = createMockContext(rules, '<p>test Test TEST</p>');
const result = await replacementTransformer.transform(ctx);
// All variants should be replaced
expect(result).toContain('exam exam exam');
});
test('should replace all occurrences case-insensitively with toggle', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'hello',
replacement: 'hi',
enabled: true,
isRegex: false,
singleInstance: false,
caseSensitive: false, // Case-insensitive
order: 1,
},
];
const ctx = createMockContext(rules, '<p>hello Hello HELLO</p>');
const result = await replacementTransformer.transform(ctx);
// All should be replaced
expect(result).toContain('hi hi hi');
const hiCount = (result.match(/hi/g) || []).length;
expect(hiCount).toBe(3);
});
});
describe('case sensitivity toggle (global scope)', () => {
test('should be case-sensitive when flag is true in global scope', async () => {
const globalRules: ReplacementRule[] = [
{
id: 'global-1',
pattern: 'world',
replacement: 'universe',
enabled: true,
isRegex: false,
singleInstance: false,
caseSensitive: true, // Explicitly case-sensitive
global: true,
order: 1,
},
];
const ctx = createMockContext(globalRules, '<p>world World WORLD</p>');
const result = await replacementTransformer.transform(ctx);
// Only lowercase "world" replaced
expect(result).toContain('universe World WORLD');
});
test('should be case-insensitive when flag is false (explicit) in global scope', async () => {
const globalRules: ReplacementRule[] = [
{
id: 'global-1',
pattern: 'world',
replacement: 'universe',
enabled: true,
isRegex: false,
singleInstance: false,
caseSensitive: false, // Case-insensitive
global: true,
order: 1,
},
];
const ctx = createMockContext(globalRules, '<p>world World WORLD</p>');
const result = await replacementTransformer.transform(ctx);
// All should be replaced
expect(result).toContain('universe universe universe');
const universeCount = (result.match(/universe/g) || []).length;
expect(universeCount).toBe(3);
});
});
describe('scope precedence', () => {
test('single-instance should override book/global for that occurrence', async () => {
const rules: ReplacementRule[] = [
{
id: 'single-1',
pattern: 'Hello',
replacement: 'Hi-once',
enabled: true,
isRegex: false,
singleInstance: true,
occurrenceIndex: 0,
sectionHref: 'chap1',
order: 1,
},
{
id: 'book-1',
pattern: 'Hello',
replacement: 'Hi-book',
enabled: true,
isRegex: false,
singleInstance: false,
order: 2,
},
{
id: 'global-1',
pattern: 'Hello',
replacement: 'Hi-global',
enabled: true,
isRegex: false,
singleInstance: false,
global: true,
order: 3,
},
];
const ctx = createMockContext(rules, '<p>Hello Hello</p>');
// Simulate sectionHref to match single-instance rule
ctx.sectionHref = 'chap1';
const result = await replacementTransformer.transform(ctx);
// First occurrence should use single-instance replacement; second should fall to book (before global)
expect(result).toContain('Hi-once Hi-book');
expect(result).not.toContain('Hi-global');
});
test('book should win over global for same pattern', async () => {
const rules: ReplacementRule[] = [
{
id: 'book-1',
pattern: 'world',
replacement: 'BOOK',
enabled: true,
isRegex: false,
singleInstance: false,
order: 1,
},
{
id: 'global-1',
pattern: 'world',
replacement: 'GLOBAL',
enabled: true,
isRegex: false,
singleInstance: false,
global: true,
order: 2,
},
];
const ctx = createMockContext(rules, '<p>world world</p>');
const result = await replacementTransformer.transform(ctx);
// Book-scope replacement should apply; global should not override
expect(result).toContain('BOOK BOOK');
expect(result).not.toContain('GLOBAL');
});
});
describe('rule ordering', () => {
test('should apply rules in order (lower order numbers first)', async () => {
const rules: ReplacementRule[] = [
{
id: '2',
pattern: 'cat',
replacement: 'dog',
enabled: true,
isRegex: false,
order: 2,
},
{
id: '1',
pattern: 'The',
replacement: 'A',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>The cat sat</p>');
const result = await replacementTransformer.transform(ctx);
// First "The" -> "A" (order 1), then "cat" -> "dog" (order 2)
expect(result).toContain('A dog sat');
});
test('should handle rules with same order', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'a',
replacement: 'A',
enabled: true,
isRegex: false,
order: 1,
},
{
id: '2',
pattern: 'b',
replacement: 'B',
enabled: true,
isRegex: false,
order: 1, // Same order
},
];
const ctx = createMockContext(rules, '<p>a b c</p>');
const result = await replacementTransformer.transform(ctx);
// Both should be applied (whole-word matching)
expect(result).toContain('A B c');
});
});
describe('enabled/disabled rules', () => {
test('should skip disabled rules', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'Hello',
replacement: 'Hi',
enabled: false,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Hello world</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Hello');
expect(result).not.toContain('Hi');
});
test('should only apply enabled rules', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'Hello',
replacement: 'Hi',
enabled: false,
isRegex: false,
order: 1,
},
{
id: '2',
pattern: 'world',
replacement: 'universe',
enabled: true,
isRegex: false,
order: 2,
},
];
const ctx = createMockContext(rules, '<p>Hello world</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Hello'); // Not replaced (disabled)
expect(result).toContain('universe'); // Replaced (enabled)
});
});
describe('error handling', () => {
test('should handle invalid regex gracefully', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: '[invalid',
replacement: 'fixed',
enabled: true,
isRegex: true,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Test content</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Test content'); // Content unchanged
});
test('should continue processing other rules after invalid regex', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: '[invalid',
replacement: 'fixed',
enabled: true,
isRegex: true,
order: 1,
},
{
id: '2',
pattern: 'Test',
replacement: 'PASSED',
enabled: true,
isRegex: false,
order: 2,
},
];
const ctx = createMockContext(rules, '<p>Test content</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('PASSED'); // Second rule should still work
});
});
describe('HTML preservation', () => {
test('should preserve HTML structure', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'text',
replacement: 'TEXT',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>Some text here</p><span>More text</span>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('<p>');
expect(result).toContain('</p>');
expect(result).toContain('<span>');
expect(result).toContain('</span>');
expect(result).toContain('TEXT');
});
test('should skip script and style tags', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'text',
replacement: 'TEXT',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(
rules,
'<p>Some text</p><script>var text = "test";</script><style>.text { color: red; }</style>',
);
const result = await replacementTransformer.transform(ctx);
// Text in <p> should be replaced
expect(result).toContain('Some TEXT');
// Text in <script> and <style> should remain unchanged
expect(result).toContain('var text = "test"');
expect(result).toContain('.text { color: red; }');
});
});
describe('unicode and special characters', () => {
test('should handle unicode characters', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'café',
replacement: 'cafe',
enabled: true,
isRegex: false,
order: 1,
wholeWord: true,
},
];
const ctx = createMockContext(rules, '<p>café</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('cafe');
expect(result).not.toContain('café');
});
test('should handle special regex characters in simple mode', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'a.b',
replacement: 'A.B',
enabled: true,
isRegex: false, // Simple mode should escape special chars
order: 1,
},
];
const ctx = createMockContext(rules, '<p>a.b and aXb</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('A.B'); // Exact match replaced
expect(result).toContain('aXb'); // Not replaced (not exact match)
});
test('should handle special regex characters in simple mode (single-instance)', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'a.b',
replacement: 'A.B',
enabled: true,
isRegex: false, // literal match
singleInstance: true, // only first occurrence
occurrenceIndex: 0, // replace only the first one
order: 1,
},
];
const input = '<p>aXb and a.b and a.b</p>';
const ctx = createMockContext(rules, input);
const result = await replacementTransformer.transform(ctx);
// Only the first literal "a.b" should be replaced → "A.B"
expect(result).toContain('A.B and a.b'); // second "a.b" remains
// aXb must NOT be replaced (the "." does NOT match X)
expect(result).toContain('aXb');
// Ensure only *one* instance was replaced
const replacedCount = (result.match(/A\.B/g) || []).length;
expect(replacedCount).toBe(1);
});
});
describe('edge cases', () => {
test('should handle empty pattern', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: '',
replacement: 'X',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>test</p>');
const result = await replacementTransformer.transform(ctx);
// empty pattern produces no changes
expect(result).toBe(ctx.content);
});
test('should handle empty replacement', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'test',
replacement: '',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>test content</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).not.toContain('test');
expect(result).toContain(' content');
});
test('should handle rules with undefined order', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'test',
replacement: 'TEST',
enabled: true,
isRegex: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
order: undefined as any,
},
];
const ctx = createMockContext(rules, '<p>test</p>');
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('TEST');
});
test('should handle complex HTML with nested elements', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'text',
replacement: 'TEXT',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(
rules,
'<div><p>Some text</p><span>More text <strong>here</strong></span></div>',
);
const result = await replacementTransformer.transform(ctx);
expect(result).toContain('Some TEXT');
expect(result).toContain('More TEXT');
expect(result).toContain('<strong>');
expect(result).toContain('</strong>');
});
});
describe('logging', () => {
test('should log transformer call with correct information', async () => {
const rules: ReplacementRule[] = [
{
id: '1',
pattern: 'test',
replacement: 'TEST',
enabled: true,
isRegex: false,
order: 1,
},
];
const ctx = createMockContext(rules, '<p>test</p>');
await replacementTransformer.transform(ctx);
});
});
describe('replacement rule management functions', () => {
describe('createReplacementRule', () => {
test('should create a rule with default values', () => {
const rule = createReplacementRule({
pattern: 'test',
replacement: 'TEST',
});
expect(rule).toMatchObject({
pattern: 'test',
replacement: 'TEST',
isRegex: false,
enabled: true,
order: 1000,
});
expect(rule.id).toBeDefined();
expect(typeof rule.id).toBe('string');
});
test('should create a rule with custom values', () => {
const rule = createReplacementRule({
pattern: '\\d+',
replacement: 'NUMBER',
isRegex: true,
enabled: false,
order: 1,
});
expect(rule).toMatchObject({
pattern: '\\d+',
replacement: 'NUMBER',
isRegex: true,
enabled: false,
order: 1,
});
});
});
describe('mergeReplacementRules', () => {
test('should merge global and book rules', () => {
const globalRules: ReplacementRule[] = [
{
id: 'global-1',
pattern: 'the',
replacement: 'THE',
enabled: true,
isRegex: false,
order: 1,
},
];
const bookRules: ReplacementRule[] = [
{
id: 'book-1',
pattern: 'cat',
replacement: 'dog',
enabled: true,
isRegex: false,
order: 2,
},
];
const merged = mergeReplacementRules(globalRules, bookRules);
expect(merged).toHaveLength(2);
expect(merged[0]!.id).toBe('global-1');
expect(merged[1]!.id).toBe('book-1');
});
test('should prioritize book rules over global rules with same ID', () => {
const globalRules: ReplacementRule[] = [
{
id: 'rule-1',
pattern: 'the',
replacement: 'THE',
enabled: true,
isRegex: false,
order: 1,
},
];
const bookRules: ReplacementRule[] = [
{
id: 'rule-1',
pattern: 'the',
replacement: '>THE<',
enabled: true,
isRegex: false,
order: 1,
},
];
const merged = mergeReplacementRules(globalRules, bookRules);
expect(merged).toHaveLength(1);
expect(merged[0]!.replacement).toBe('>THE<'); // Book rule wins
});
test('should sort rules by order', () => {
const globalRules: ReplacementRule[] = [
{
id: 'rule-3',
pattern: 'c',
replacement: 'C',
enabled: true,
isRegex: false,
order: 3,
},
{
id: 'rule-1',
pattern: 'a',
replacement: 'A',
enabled: true,
isRegex: false,
order: 1,
},
];
const bookRules: ReplacementRule[] = [
{
id: 'rule-2',
pattern: 'b',
replacement: 'B',
enabled: true,
isRegex: false,
order: 2,
},
];
const merged = mergeReplacementRules(globalRules, bookRules);
expect(merged[0]!.pattern).toBe('a');
expect(merged[1]!.pattern).toBe('b');
expect(merged[2]!.pattern).toBe('c');
});
test('should handle undefined rules', () => {
const merged = mergeReplacementRules(undefined, undefined);
expect(merged).toEqual([]);
});
});
describe('validateReplacementRulePattern', () => {
test('should validate simple string pattern', () => {
const result = validateReplacementRulePattern('test', false);
expect(result.valid).toBe(true);
});
test('should reject empty pattern', () => {
const result = validateReplacementRulePattern('', false);
expect(result.valid).toBe(false);
expect(result.error).toContain('empty');
});
test('should validate valid regex pattern', () => {
const result = validateReplacementRulePattern('\\d+', true);
expect(result.valid).toBe(true);
});
test('should reject invalid regex pattern', () => {
const result = validateReplacementRulePattern('[invalid', true);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
});
});
});