956c71cd7b
Replace ESLint with Biome for ~14x faster linting (~360ms vs ~5s). - Add biome.json with rules matching ESLint parity (Next.js, a11y, TypeScript, unused vars/imports) - Remove eslint, @typescript-eslint/*, eslint-config-next, eslint-plugin-jsx-a11y, @eslint/* from deps - Remove eslint.config.mjs - Update lint script to: tsgo --noEmit && biome check . - Fix 11 real code issues caught by Biome (banned types, explicit any, unsafe finally, unreachable code, shadowed names) - Disable Biome formatter (Prettier stays for Tailwind class sorting) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1167 lines
33 KiB
TypeScript
1167 lines
33 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: { proofreadRules: [] },
|
|
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: () => ({ proofreadRules: [] }),
|
|
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 { proofreadTransformer } from '@/services/transformers/proofread';
|
|
import { TransformContext } from '@/services/transformers/types';
|
|
import { ViewSettings, ProofreadRule } from '@/types/book';
|
|
import { validateReplacementRulePattern } from '@/store/proofreadStore';
|
|
|
|
describe('proofreadTransformer', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const createMockContext = (
|
|
rules: ProofreadRule[] | undefined,
|
|
content: string,
|
|
sectionHref?: string,
|
|
): TransformContext => {
|
|
const viewSettings = {
|
|
proofreadRules: rules,
|
|
} as Partial<ViewSettings> as ViewSettings;
|
|
|
|
return {
|
|
bookKey: 'test-book',
|
|
viewSettings,
|
|
userLocale: 'en',
|
|
content,
|
|
isFixedLayout: false,
|
|
sectionHref,
|
|
transformers: ['proofread'],
|
|
};
|
|
};
|
|
|
|
describe('basic functionality', () => {
|
|
test('should return content unchanged when no rules', async () => {
|
|
const ctx = createMockContext(undefined, '<p>Hello world</p>');
|
|
const result = await proofreadTransformer.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 proofreadTransformer.transform(ctx);
|
|
expect(result).toContain('Hello world');
|
|
});
|
|
|
|
test('should apply simple string replacement with whole-word matching', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'Hello',
|
|
replacement: 'Hi',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Hello world</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('Hi world');
|
|
expect(result).not.toContain('Hello');
|
|
});
|
|
|
|
test('should apply multiple simple replacements', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'cat',
|
|
replacement: 'dog',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'The',
|
|
replacement: 'A',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat sat</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('A dog sat');
|
|
});
|
|
|
|
test('should replace all occurrences, not just first', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'the',
|
|
replacement: 'THE',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>the cat and the dog</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('THE cat and THE dog');
|
|
});
|
|
|
|
test('should not replace partial word matches with whole-word enabled', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'cat',
|
|
replacement: 'dog',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat sat on the category</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('dog');
|
|
expect(result).toContain('category'); // Should not replace "cat" in "category"
|
|
});
|
|
|
|
test('should replace CJK characters correctly', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '猫',
|
|
replacement: '狗',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>我有一只猫。</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('我有一只狗。');
|
|
expect(result).not.toContain('猫');
|
|
});
|
|
|
|
test('should replace multiple different words correctly', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '猫',
|
|
replacement: '狗',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: '我',
|
|
replacement: '你',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>我有一只猫。</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('你有一只狗。');
|
|
expect(result).not.toContain('我');
|
|
expect(result).not.toContain('猫');
|
|
});
|
|
});
|
|
|
|
describe('regex functionality', () => {
|
|
test('should apply regex replacement', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '\\d+',
|
|
replacement: 'NUMBER',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>I have 5 apples and 10 oranges</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
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: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '\\bcat\\b',
|
|
replacement: 'dog',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat sat on the category</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('dog');
|
|
expect(result).toContain('category');
|
|
});
|
|
|
|
test('should handle case-sensitive regex', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'the',
|
|
replacement: 'THE',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat and the dog</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('THE');
|
|
expect(result).toContain('The cat'); // uppercase "The" stays untouched
|
|
});
|
|
|
|
test('should handle case-insensitive regex when specified', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'the',
|
|
replacement: 'THE',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: false,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat and the dog</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Both "The" and "the" should be replaced
|
|
expect(result).toContain('THE cat and THE dog');
|
|
});
|
|
});
|
|
|
|
describe('selection scope', () => {
|
|
test('should skip selection rules without matching sectionHref', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'selection',
|
|
pattern: 'test',
|
|
replacement: 'REPLACED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
sectionHref: 'chapter1.html',
|
|
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<html><body><p>test content</p></body></html>',
|
|
'chapter2.html',
|
|
);
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Should not replace because sectionHref doesn't match
|
|
expect(result).toContain('test content');
|
|
expect(result).not.toContain('REPLACED');
|
|
});
|
|
|
|
test('should process selection rules with matching sectionHref', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'selection',
|
|
pattern: 'test',
|
|
replacement: 'REPLACED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
sectionHref: 'chapter1.html',
|
|
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<html><body><p>test content</p></body></html>',
|
|
'chapter1.html',
|
|
);
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).not.toContain('test content');
|
|
expect(result).toContain('REPLACED');
|
|
});
|
|
|
|
test('should handle selection rules with hash fragments in sectionHref', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'selection',
|
|
pattern: 'test',
|
|
replacement: 'REPLACED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
sectionHref: 'chapter1.html#section2',
|
|
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<html><body><p>test content</p></body></html>',
|
|
'chapter1.html#section3',
|
|
);
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).not.toContain('test content');
|
|
expect(result).toContain('REPLACED');
|
|
});
|
|
|
|
test('should not process selection rules without sectionHref', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'selection',
|
|
pattern: 'test',
|
|
replacement: 'REPLACED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
cfi: 'epubcfi(/6/14!/4/2,/1:0,/1:4)',
|
|
// No sectionHref
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<html><body><p>test content</p></body></html>',
|
|
'chapter1.html',
|
|
);
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Selection rules without sectionHref should be skipped
|
|
expect(result).toContain('test content');
|
|
});
|
|
|
|
test('should handle invalid CFI gracefully', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'selection',
|
|
pattern: 'test',
|
|
replacement: 'REPLACED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
sectionHref: 'chapter1.html',
|
|
cfi: 'invalid-cfi',
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test content</p>', 'chapter1.html');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Should not crash, content should remain unchanged
|
|
expect(result).toBeDefined();
|
|
expect(result).toContain('test');
|
|
});
|
|
});
|
|
|
|
describe('case sensitivity (book scope)', () => {
|
|
test('should be case-sensitive by default for book scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'hello',
|
|
replacement: 'hi',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Hello world hello there Hello</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Only lowercase "hello" should match
|
|
expect(result).toContain('Hello world hi there Hello');
|
|
const helloCount = (result.match(/Hello/g) || []).length;
|
|
const hiCount = (result.match(/\bhi\b/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: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'world',
|
|
replacement: 'universe',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>world and world and World</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Both lowercase "world" should match, "World" should not
|
|
expect(result).toContain('universe and universe and World');
|
|
const worldCount = (result.match(/World/g) || []).length;
|
|
const universeCount = (result.match(/universe/g) || []).length;
|
|
expect(worldCount).toBe(1); // One "World" remains
|
|
expect(universeCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('case sensitivity (library scope)', () => {
|
|
test('should be case-sensitive by default for library scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: 'library-1',
|
|
scope: 'library',
|
|
pattern: 'book',
|
|
replacement: 'tome',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>book and Book and BOOK</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Only lowercase "book" should match
|
|
expect(result).toContain('tome and Book and BOOK');
|
|
const bookCount = (result.match(/Book|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 library scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: 'library-1',
|
|
scope: 'library',
|
|
pattern: 'test',
|
|
replacement: 'exam',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>test and test and Test and TEST</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Only lowercase "test" should match
|
|
expect(result).toContain('exam and exam and Test and TEST');
|
|
const testCount = (result.match(/Test|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 (book scope)', () => {
|
|
test('should replace case-sensitive when flag is true', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'test',
|
|
replacement: 'exam',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test Test TEST</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Only lowercase "test" should be replaced
|
|
expect(result).toContain('exam Test TEST');
|
|
});
|
|
|
|
test('should replace case-insensitive when flag is false', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'test',
|
|
replacement: 'exam',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: false,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test Test TEST</p>');
|
|
const result = await proofreadTransformer.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: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'hello',
|
|
replacement: 'hi',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: false,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>hello Hello HELLO</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// All should be replaced
|
|
expect(result).toContain('hi hi hi');
|
|
const hiCount = (result.match(/\bhi\b/gi) || []).length;
|
|
expect(hiCount).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('case sensitivity toggle (library scope)', () => {
|
|
test('should be case-sensitive when flag is true in library scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: 'library-1',
|
|
scope: 'library',
|
|
pattern: 'world',
|
|
replacement: 'universe',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>world World WORLD</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Only lowercase "world" replaced
|
|
expect(result).toContain('universe World WORLD');
|
|
});
|
|
|
|
test('should be case-insensitive when flag is false in library scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: 'library-1',
|
|
scope: 'library',
|
|
pattern: 'world',
|
|
replacement: 'universe',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: false,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>world World WORLD</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// All should be replaced
|
|
expect(result).toContain('universe universe universe');
|
|
const universeCount = (result.match(/universe/gi) || []).length;
|
|
expect(universeCount).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('scope precedence', () => {
|
|
test('selection should be processed first, then book, then library', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: 'library-1',
|
|
scope: 'library',
|
|
pattern: 'world',
|
|
replacement: 'LIBRARY',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 3,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: 'book-1',
|
|
scope: 'book',
|
|
pattern: 'world',
|
|
replacement: 'BOOK',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>world world</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Book-scope replacement should apply first due to scope ordering
|
|
expect(result).toContain('BOOK BOOK');
|
|
expect(result).not.toContain('LIBRARY');
|
|
});
|
|
|
|
test('should respect order within same scope', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'cat',
|
|
replacement: 'dog',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'The',
|
|
replacement: 'A',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat sat</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// First "The" -> "A" (order 1), then "cat" -> "dog" (order 2)
|
|
expect(result).toContain('A dog sat');
|
|
});
|
|
});
|
|
|
|
describe('rule ordering', () => {
|
|
test('should apply rules in order (lower order numbers first)', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'cat',
|
|
replacement: 'dog',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'The',
|
|
replacement: 'A',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>The cat sat</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('A dog sat');
|
|
});
|
|
|
|
test('should handle rules with same order', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'a',
|
|
replacement: 'A',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'b',
|
|
replacement: 'B',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>a b c</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// Both should be applied
|
|
expect(result).toContain('A B c');
|
|
});
|
|
});
|
|
|
|
describe('enabled/disabled rules', () => {
|
|
test('should skip disabled rules', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'Hello',
|
|
replacement: 'Hi',
|
|
enabled: false,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Hello world</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('Hello');
|
|
expect(result).not.toContain('Hi');
|
|
});
|
|
|
|
test('should only apply enabled rules', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'Hello',
|
|
replacement: 'Hi',
|
|
enabled: false,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'world',
|
|
replacement: 'universe',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Hello world</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('Hello');
|
|
expect(result).toContain('universe');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
test('should handle invalid regex gracefully', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '[invalid',
|
|
replacement: 'fixed',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Test content</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('Test content');
|
|
});
|
|
|
|
test('should continue processing other rules after invalid regex', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '[invalid',
|
|
replacement: 'fixed',
|
|
enabled: true,
|
|
isRegex: true,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: false,
|
|
},
|
|
{
|
|
id: '2',
|
|
scope: 'book',
|
|
pattern: 'Test',
|
|
replacement: 'PASSED',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 2,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Test content</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('PASSED');
|
|
});
|
|
});
|
|
|
|
describe('HTML preservation', () => {
|
|
test('should preserve HTML structure', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'text',
|
|
replacement: 'TEXT',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>Some text here</p><span>More text</span>');
|
|
const result = await proofreadTransformer.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: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'text',
|
|
replacement: 'TEXT',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<p>Some text</p><script>var text = "test";</script><style>.text { color: red; }</style>',
|
|
);
|
|
const result = await proofreadTransformer.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: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'café',
|
|
replacement: 'cafe',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>café</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('cafe');
|
|
expect(result).not.toContain('café');
|
|
});
|
|
|
|
test('should handle special regex characters in simple mode', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'a.b',
|
|
replacement: 'A.B',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
|
|
const ctx = createMockContext(rules, '<p>a.b and aXb</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('A.B');
|
|
expect(result).toContain('aXb');
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
test('should handle empty pattern', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: '',
|
|
replacement: 'X',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
// empty pattern produces no changes (filtered out by .filter(r => r.pattern.trim()))
|
|
expect(result).toBe(ctx.content);
|
|
});
|
|
|
|
test('should handle empty replacement', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'test',
|
|
replacement: '',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test content</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).not.toContain('test');
|
|
expect(result).toContain('content');
|
|
});
|
|
|
|
test('should handle rules with undefined order', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'test',
|
|
replacement: 'TEST',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 0,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(rules, '<p>test</p>');
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('TEST');
|
|
});
|
|
|
|
test('should handle complex HTML with nested elements', async () => {
|
|
const rules: ProofreadRule[] = [
|
|
{
|
|
id: '1',
|
|
scope: 'book',
|
|
pattern: 'text',
|
|
replacement: 'TEXT',
|
|
enabled: true,
|
|
isRegex: false,
|
|
caseSensitive: true,
|
|
order: 1,
|
|
wholeWord: true,
|
|
},
|
|
];
|
|
const ctx = createMockContext(
|
|
rules,
|
|
'<div><p>Some text</p><span>More text <strong>here</strong></span></div>',
|
|
);
|
|
const result = await proofreadTransformer.transform(ctx);
|
|
|
|
expect(result).toContain('Some TEXT');
|
|
expect(result).toContain('More TEXT');
|
|
expect(result).toContain('<strong>');
|
|
expect(result).toContain('</strong>');
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|