Files
readest/apps/readest-app/src/__tests__/utils/annotator-util.test.ts
T
Huang Xin ef603852b7 feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).

Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.

Closes #4085

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:33:28 +02:00

300 lines
9.3 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import {
buildTTSSentenceHighlight,
getExternalDragHandle,
getHighlightColorLabel,
removeBookNoteOverlays,
toParentViewportPoint,
} from '@/app/reader/utils/annotatorUtil';
import { Point } from '@/utils/sel';
import { BookNote, HighlightColor, HighlightStyle, UserHighlightColor } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { FoliateView, NOTE_PREFIX } from '@/types/view';
describe('getExternalDragHandle', () => {
const currentStart: Point = { x: 100, y: 200 };
const currentEnd: Point = { x: 300, y: 200 };
it('forward drag — externalDragPoint closer to end → returns end', () => {
const result = getExternalDragHandle(currentStart, currentEnd, { x: 280, y: 200 });
expect(result).toBe('end');
});
it('backward drag — externalDragPoint closer to start → returns start', () => {
const result = getExternalDragHandle(currentStart, currentEnd, { x: 120, y: 200 });
expect(result).toBe('start');
});
it('returns null when externalDragPoint is null', () => {
const result = getExternalDragHandle(currentStart, currentEnd, null);
expect(result).toBeNull();
});
it('returns null when externalDragPoint is undefined', () => {
const result = getExternalDragHandle(currentStart, currentEnd);
expect(result).toBeNull();
});
it('vertical text — works with vertical coordinates', () => {
const vStart: Point = { x: 200, y: 100 };
const vEnd: Point = { x: 200, y: 400 };
const result = getExternalDragHandle(vStart, vEnd, { x: 200, y: 350 });
expect(result).toBe('end');
});
it('equal distance — returns end (deterministic tie-breaking)', () => {
// Midpoint between start(100,200) and end(300,200) is (200,200)
// distToStart === distToEnd, so !(distToStart < distToEnd) → returns 'end'
const result = getExternalDragHandle(currentStart, currentEnd, { x: 200, y: 200 });
expect(result).toBe('end');
});
});
describe('toParentViewportPoint', () => {
it('adds frameRect offset to coordinates', () => {
const mockFrameElement = {
getBoundingClientRect: vi.fn(() => ({
top: 50,
left: 80,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: vi.fn(),
})),
};
const doc = {
defaultView: {
frameElement: mockFrameElement,
},
} as unknown as Document;
const result = toParentViewportPoint(doc, 100, 200);
expect(result).toEqual({ x: 180, y: 250 });
});
it('defaults to {0,0} offset when no frameElement (detached doc)', () => {
const doc = {
defaultView: null,
} as unknown as Document;
const result = toParentViewportPoint(doc, 100, 200);
expect(result).toEqual({ x: 100, y: 200 });
});
it('handles non-zero iframe offset (e.g., sidebar shifts iframe right)', () => {
const mockFrameElement = {
getBoundingClientRect: vi.fn(() => ({
top: 0,
left: 250,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: vi.fn(),
})),
};
const doc = {
defaultView: {
frameElement: mockFrameElement,
},
} as unknown as Document;
const result = toParentViewportPoint(doc, 50, 100);
expect(result).toEqual({ x: 300, y: 100 });
});
});
describe('getHighlightColorLabel', () => {
const makeSettings = (
userHighlightColors: UserHighlightColor[],
defaultHighlightLabels: Partial<Record<string, string>> = {},
): SystemSettings =>
({
globalReadSettings: {
userHighlightColors,
defaultHighlightLabels,
},
}) as SystemSettings;
it('returns the user-set label for a built-in color', () => {
const settings = makeSettings([], { yellow: 'Foreshadowing' });
expect(getHighlightColorLabel(settings, 'yellow')).toBe('Foreshadowing');
});
it('returns the user-set label for a hex color, matching case-insensitively', () => {
const settings = makeSettings([{ hex: '#aabbcc', label: 'Romance' }]);
expect(getHighlightColorLabel(settings, '#AABBCC')).toBe('Romance');
});
it('returns undefined when the user has not set a label', () => {
const settings = makeSettings([]);
expect(getHighlightColorLabel(settings, 'green')).toBeUndefined();
expect(getHighlightColorLabel(settings, '#123456')).toBeUndefined();
});
it('ignores labels that collapse to whitespace', () => {
const settings = makeSettings([{ hex: '#abcdef', label: ' ' }], { red: ' ' });
expect(getHighlightColorLabel(settings, '#abcdef')).toBeUndefined();
expect(getHighlightColorLabel(settings, 'red')).toBeUndefined();
});
});
describe('removeBookNoteOverlays', () => {
const makeView = () => {
const addAnnotation = vi.fn();
const view = { addAnnotation } as unknown as FoliateView;
return { view, addAnnotation };
};
const baseNote = (overrides: Partial<BookNote> = {}): BookNote => ({
id: 'id-1',
type: 'annotation',
cfi: 'epubcfi(/6/4!/4/2)',
note: '',
createdAt: 1,
updatedAt: 1,
...overrides,
});
it('removes only the highlight overlay for a highlight-only annotation', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'highlight', color: 'yellow' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
const passed = addAnnotation.mock.calls[0]![0] as BookNote & { value: string };
expect(passed.value.startsWith(NOTE_PREFIX)).toBe(false);
});
it('removes only the note overlay for a note-only annotation', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ note: 'my comment' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(
expect.objectContaining({ value: `${NOTE_PREFIX}${note.cfi}` }),
true,
);
});
it('removes both overlays when the annotation has a highlight and a note', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'underline', color: 'red', note: 'my comment' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(2);
const values = addAnnotation.mock.calls.map(
(call) => (call[0] as BookNote & { value: string }).value,
);
expect(values).toContain(note.cfi);
expect(values).toContain(`${NOTE_PREFIX}${note.cfi}`);
for (const call of addAnnotation.mock.calls) {
expect(call[1]).toBe(true);
}
});
it('does nothing for a bookmark (no highlight, no note text)', () => {
const { view, addAnnotation } = makeView();
const bookmark = baseNote({ type: 'bookmark' });
removeBookNoteOverlays(view, bookmark);
expect(addAnnotation).not.toHaveBeenCalled();
});
it('treats whitespace-only note text as empty and skips the note overlay', () => {
const { view, addAnnotation } = makeView();
const note = baseNote({ style: 'highlight', note: ' \n ' });
removeBookNoteOverlays(view, note);
expect(addAnnotation).toHaveBeenCalledTimes(1);
expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true);
});
it('is a no-op when view is null', () => {
expect(() => removeBookNoteOverlays(null, baseNote({ style: 'highlight' }))).not.toThrow();
});
});
describe('buildTTSSentenceHighlight', () => {
const params = {
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
text: 'A spoken sentence.',
style: 'highlight' as HighlightStyle,
color: 'yellow' as HighlightColor,
page: 7,
};
it('builds an annotation BookNote when none exists at the cfi', () => {
const note = buildTTSSentenceHighlight([], params, 1000);
expect(note).not.toBeNull();
expect(note).toMatchObject({
type: 'annotation',
cfi: params.cfi,
text: params.text,
style: 'highlight',
color: 'yellow',
page: 7,
note: '',
createdAt: 1000,
updatedAt: 1000,
});
expect(typeof note!.id).toBe('string');
expect(note!.id.length).toBeGreaterThan(0);
});
it('returns null (skip) when a live annotation already exists at the cfi', () => {
const existing: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([existing], params, 1000)).toBeNull();
});
it('builds when the only note at the cfi is soft-deleted', () => {
const deleted: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
deletedAt: 5,
};
expect(buildTTSSentenceHighlight([deleted], params, 1000)).not.toBeNull();
});
it('builds when the note at the cfi is a non-annotation (bookmark)', () => {
const bookmark: BookNote = {
id: 'b1',
type: 'bookmark',
cfi: params.cfi,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([bookmark], params, 1000)).not.toBeNull();
});
});