forked from akai/readest
cbdc3b8f52
* feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
260 lines
8.3 KiB
TypeScript
260 lines
8.3 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
import { EnvProvider } from '@/context/EnvContext';
|
|
import ProofreadPopup from '@/app/reader/components/annotator/ProofreadPopup';
|
|
|
|
vi.mock('@/services/environment', async () => {
|
|
const actual = await vi.importActual('@/services/environment');
|
|
|
|
const mockAppService = {
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
// EnvProvider's mount effect calls appService.loadSettings() to seed
|
|
// replica sync. Returning a settings object without replicaDeviceId
|
|
// makes init early-exit cleanly (no warn, no real network).
|
|
loadSettings: vi.fn().mockResolvedValue({}),
|
|
// Add any other methods from AppService interface
|
|
};
|
|
|
|
return {
|
|
...actual,
|
|
default: {
|
|
getAppService: vi.fn().mockResolvedValue(mockAppService),
|
|
},
|
|
};
|
|
});
|
|
|
|
global.ResizeObserver = class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
|
|
function renderWithProviders(ui: React.ReactNode) {
|
|
return render(<EnvProvider>{ui}</EnvProvider>);
|
|
}
|
|
|
|
describe('ProofreadPopup Component', () => {
|
|
const mockOnConfirm = vi.fn();
|
|
const mockOnClose = vi.fn();
|
|
|
|
const defaultProps = {
|
|
bookKey: 'test-book',
|
|
isVertical: false,
|
|
selectedText: 'test word',
|
|
selection: {
|
|
key: 'test-book',
|
|
text: 'test word',
|
|
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
|
|
index: 0,
|
|
range: {
|
|
deleteContents: vi.fn(),
|
|
insertNode: vi.fn(),
|
|
startContainer: document.createTextNode('test word here'),
|
|
endContainer: document.createTextNode('test word here'),
|
|
startOffset: 5,
|
|
endOffset: 9,
|
|
} as unknown as Range,
|
|
page: 1,
|
|
},
|
|
position: { point: { x: 100, y: 100 } },
|
|
trianglePosition: { point: { x: 100, y: 100 } },
|
|
popupWidth: 440,
|
|
popupHeight: 200,
|
|
onConfirm: mockOnConfirm,
|
|
onDismiss: mockOnClose,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render default replacement scope options', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
expect(screen.getByText('Current selection')).toBeTruthy();
|
|
});
|
|
|
|
it('should render the replacement text input field', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
expect(input).toBeTruthy();
|
|
});
|
|
|
|
it('should render the case sensitive checkbox', () => {
|
|
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
expect(screen.getByText('Case sensitive:')).toBeTruthy();
|
|
expect(container.querySelector('input[type="checkbox"]')).toBeTruthy();
|
|
});
|
|
|
|
it('should render the Apply button', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
expect(screen.getByText('Apply')).toBeTruthy();
|
|
});
|
|
|
|
it('should display selected text preview', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
expect(screen.getByText(/Selected text:/)).toBeTruthy();
|
|
expect(screen.getByText(/"test word"/)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('Case Sensitive Checkbox', () => {
|
|
it('should be checked by default (case-sensitive)', () => {
|
|
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(true);
|
|
});
|
|
|
|
it('should toggle when clicked', async () => {
|
|
const { container } = renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const checkbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
|
expect(checkbox.checked).toBe(true);
|
|
|
|
fireEvent.click(checkbox);
|
|
expect(checkbox.checked).toBe(false);
|
|
|
|
fireEvent.click(checkbox);
|
|
expect(checkbox.checked).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Replacement Text Input', () => {
|
|
it('should update value when user types', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'new text' } });
|
|
|
|
expect(input.value).toBe('new text');
|
|
});
|
|
|
|
it('should trim whitespace from replacement text', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
fireEvent.change(input, { target: { value: ' trimmed ' } });
|
|
|
|
const confirmButton = screen.getByText('Apply');
|
|
fireEvent.click(confirmButton);
|
|
|
|
expect(mockOnConfirm).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
replacement: 'trimmed',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Scope Selection Handlers', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
const createValidSelection = () => ({
|
|
...defaultProps,
|
|
selection: {
|
|
...defaultProps.selection,
|
|
text: 'word',
|
|
cfi: 'epubcfi(/6/4[chap01ref]!/4/2/1:0)',
|
|
range: {
|
|
deleteContents: vi.fn(),
|
|
insertNode: vi.fn(),
|
|
startContainer: document.createTextNode('test word here'),
|
|
endContainer: document.createTextNode('test word here'),
|
|
startOffset: 5,
|
|
endOffset: 9,
|
|
} as unknown as Range,
|
|
},
|
|
});
|
|
|
|
it('should call onConfirm with correct scope for "selection"', async () => {
|
|
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
fireEvent.change(input, { target: { value: 'replacement' } });
|
|
|
|
const applyButton = screen.getByText('Apply');
|
|
fireEvent.click(applyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'selection' }));
|
|
});
|
|
});
|
|
|
|
it('should call onConfirm with correct scope for "book"', async () => {
|
|
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
fireEvent.change(input, { target: { value: 'replacement' } });
|
|
|
|
const scopeSelect = screen.getByRole('combobox');
|
|
fireEvent.change(scopeSelect, { target: { value: 'book' } });
|
|
|
|
const applyButton = screen.getByText('Apply');
|
|
fireEvent.click(applyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'book' }));
|
|
});
|
|
});
|
|
|
|
it('should call onConfirm with correct scope for "library"', async () => {
|
|
renderWithProviders(<ProofreadPopup {...createValidSelection()} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
fireEvent.change(input, { target: { value: 'replacement' } });
|
|
|
|
const scopeSelect = screen.getByRole('combobox');
|
|
fireEvent.change(scopeSelect, { target: { value: 'library' } });
|
|
|
|
const applyButton = screen.getByText('Apply');
|
|
fireEvent.click(applyButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnConfirm).toHaveBeenCalledWith(expect.objectContaining({ scope: 'library' }));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Click Outside Behavior', () => {
|
|
it('should not call onClose when clicking inside the menu', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
const input = screen.getByPlaceholderText('Enter text...');
|
|
fireEvent.mouseDown(input);
|
|
|
|
expect(mockOnClose).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Manage Replacement Rules Shortcut', () => {
|
|
it('should not render manage button when onManage is not provided', () => {
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} />);
|
|
|
|
expect(screen.queryByLabelText('Proofread Replacement Rules')).toBeNull();
|
|
});
|
|
|
|
it('should render manage button and invoke onManage when provided', () => {
|
|
const mockOnManage = vi.fn();
|
|
renderWithProviders(<ProofreadPopup {...defaultProps} onManage={mockOnManage} />);
|
|
|
|
const button = screen.getByLabelText('Proofread Replacement Rules');
|
|
expect(button).toBeTruthy();
|
|
|
|
fireEvent.click(button);
|
|
expect(mockOnManage).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|