Files
readest/apps/readest-app/src/__tests__/utils/sel.test.ts
T
Huang Xin d326e1c73d fix: hide popup triangle when inside popup + EPUB image-only paragraph rendering (#4121)
- Popup: hide the inner triangle when its anchor point lands inside
  the popup body. Extracted as a generic `isPointInRect` helper in
  `sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
  MOBI conversions) — clear hardcoded width/height and apply
  multiply blend for dark themes so the image doesn't sit on a
  colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
  smaller screens.
- foliate-js: submodule bump.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:12:37 +02:00

514 lines
20 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import type { Rect, Position } from '@/utils/sel';
// We need to test the non-exported helpers via the exported functions that use them.
// frameRect and pointIsInView are non-exported, but exercised through getPosition/getPopupPosition.
// constrainPointWithinRect is also non-exported but exercised through getPosition.
// For getPopupPosition we can test directly.
import { getPopupPosition, isPointInRect } from '@/utils/sel';
describe('sel utilities', () => {
describe('isPointInRect', () => {
const rect: Rect = { left: 100, top: 50, right: 300, bottom: 200 };
it('returns true for a point strictly inside the rect', () => {
expect(isPointInRect({ x: 200, y: 100 }, rect)).toBe(true);
});
it('treats edges as outside with the default 1px padding', () => {
expect(isPointInRect({ x: 100, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 300, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 50 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 200 }, rect)).toBe(false);
});
it('returns true on edges when padding is 0', () => {
expect(isPointInRect({ x: 100, y: 50 }, rect, 0)).toBe(true);
expect(isPointInRect({ x: 300, y: 200 }, rect, 0)).toBe(true);
});
it('returns false when x is outside', () => {
expect(isPointInRect({ x: 99, y: 100 }, rect)).toBe(false);
expect(isPointInRect({ x: 301, y: 100 }, rect)).toBe(false);
});
it('returns false when y is outside', () => {
expect(isPointInRect({ x: 200, y: 49 }, rect)).toBe(false);
expect(isPointInRect({ x: 200, y: 201 }, rect)).toBe(false);
});
});
describe('getPopupPosition', () => {
const boundingRect: Rect = { top: 0, right: 800, bottom: 600, left: 0 };
describe('direction: up', () => {
it('should position popup above the point', () => {
const position: Position = { point: { x: 400, y: 200 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 400 - 200/2 = 300
// y = 200 - 100 = 100
expect(result.point.x).toBe(300);
expect(result.point.y).toBe(100);
expect(result.dir).toBe('up');
});
it('should clamp popup to left edge with padding', () => {
const position: Position = { point: { x: 50, y: 200 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 50 - 100 = -50, clamped to padding 10
expect(result.point.x).toBe(10);
});
it('should clamp popup to top edge with padding', () => {
const position: Position = { point: { x: 400, y: 50 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// y = 50 - 100 = -50, clamped to padding 10
expect(result.point.y).toBe(10);
});
it('should clamp popup to right edge with padding', () => {
const position: Position = { point: { x: 750, y: 300 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 750 - 100 = 650, max = 800 - 0 - 10 - 200 = 590
expect(result.point.x).toBe(590);
});
it('should clamp popup to bottom edge with padding', () => {
const position: Position = { point: { x: 400, y: 590 }, dir: 'up' };
// y = 590 - 100 = 490, max = 600 - 0 - 10 - 100 = 490
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
expect(result.point.y).toBe(490);
});
});
describe('direction: down', () => {
it('should position popup below the point with 6px offset', () => {
const position: Position = { point: { x: 400, y: 200 }, dir: 'down' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 400 - 100 = 300
// y = 200 + 6 = 206
expect(result.point.x).toBe(300);
expect(result.point.y).toBe(206);
expect(result.dir).toBe('down');
});
it('should clamp when popup overflows bottom', () => {
const position: Position = { point: { x: 400, y: 550 }, dir: 'down' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// y = 550 + 6 = 556, max = 600 - 0 - 10 - 100 = 490
expect(result.point.y).toBe(490);
});
});
describe('direction: left', () => {
it('should position popup to the left of the point', () => {
const position: Position = { point: { x: 400, y: 300 }, dir: 'left' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 400 - 200 = 200
// y = 300 - 50 = 250
expect(result.point.x).toBe(200);
expect(result.point.y).toBe(250);
expect(result.dir).toBe('left');
});
it('should clamp when popup overflows left', () => {
const position: Position = { point: { x: 50, y: 300 }, dir: 'left' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 50 - 200 = -150, clamped to 10
expect(result.point.x).toBe(10);
});
});
describe('direction: right', () => {
it('should position popup to the right of the point with 6px offset', () => {
const position: Position = { point: { x: 300, y: 300 }, dir: 'right' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 300 + 6 = 306
// y = 300 - 50 = 250
expect(result.point.x).toBe(306);
expect(result.point.y).toBe(250);
expect(result.dir).toBe('right');
});
it('should clamp when popup overflows right', () => {
const position: Position = { point: { x: 700, y: 300 }, dir: 'right' };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// x = 700 + 6 = 706, max = 800 - 0 - 10 - 200 = 590
expect(result.point.x).toBe(590);
});
});
describe('no direction', () => {
it('should default to top-left (0,0) and apply clamping', () => {
const position: Position = { point: { x: 400, y: 300 } };
const result = getPopupPosition(position, boundingRect, 200, 100, 10);
// No dir matched, popupPoint stays {0,0}
// x = 0 < 10 => 10
// y = 0 < 10 => 10
expect(result.point.x).toBe(10);
expect(result.point.y).toBe(10);
});
});
describe('non-zero origin bounding rect', () => {
it('should respect bounding rect offset for right clamping', () => {
const rect: Rect = { top: 100, right: 500, bottom: 400, left: 100 };
const position: Position = { point: { x: 380, y: 150 }, dir: 'down' };
const result = getPopupPosition(position, rect, 200, 100, 10);
// x = 380 - 100 = 280, max_x = (500-100) - 10 - 200 = 190 => clamped to 190
expect(result.point.x).toBe(190);
});
it('should respect bounding rect offset for bottom clamping', () => {
const rect: Rect = { top: 100, right: 500, bottom: 400, left: 100 };
const position: Position = { point: { x: 200, y: 280 }, dir: 'down' };
const result = getPopupPosition(position, rect, 100, 100, 10);
// x = 200 - 50 = 150, within bounds
// y = 280 + 6 = 286, max_y = (400-100) - 10 - 100 = 190 => clamped to 190
expect(result.point.y).toBe(190);
});
});
describe('zero-size popup', () => {
it('should handle zero width and height', () => {
const position: Position = { point: { x: 400, y: 300 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 0, 0, 0);
expect(result.point.x).toBe(400);
expect(result.point.y).toBe(300);
});
});
describe('large popup exceeding bounds', () => {
it('should clamp a popup larger than the bounding rect', () => {
const position: Position = { point: { x: 400, y: 300 }, dir: 'up' };
const result = getPopupPosition(position, boundingRect, 1000, 800, 10);
// x = 400 - 500 = -100 => clamped to 10
// then right clamp: 10 + 1000 > 800 - 0 - 10 => x = 800 - 10 - 1000 = -210
// -210 < 10 => re-clamped to 10 (left clamp happens first, then right clamp overrides)
// Actually: left clamp sets x=10, then right check: 10+1000=1010 > 790 => x = -210
// The function applies clamping sequentially: first min, then max. So final x = -210.
expect(result.point.x).toBe(-210);
});
});
});
describe('getPosition (exercises frameRect, constrainPointWithinRect, pointIsInView)', () => {
beforeEach(() => {
// Set window dimensions for pointIsInView
Object.defineProperty(window, 'innerWidth', { value: 1024, writable: true });
Object.defineProperty(window, 'innerHeight', { value: 768, writable: true });
});
it('should return zero-point when both start and end are outside the view', async () => {
const { getPosition } = await import('@/utils/sel');
// Create a mock Range that returns rects outside the viewport
const mockRange = {
getClientRects: () =>
[{ top: -200, right: -100, bottom: -150, left: -200 }] as unknown as DOMRectList,
commonAncestorContainer: document.createElement('div'),
} as unknown as Range;
const rect: Rect = { top: 0, right: 1024, bottom: 768, left: 0 };
const result = getPosition(mockRange, rect, 10);
// The constrained points should be clamped within rect, but they need to
// pass pointIsInView which checks against window dimensions
expect(result.point).toBeDefined();
});
it('should handle a TextSelection with rect property', async () => {
const { getPosition } = await import('@/utils/sel');
const mockTextSelection = {
range: {
getClientRects: () =>
[{ top: 100, right: 300, bottom: 120, left: 200 }] as unknown as DOMRectList,
commonAncestorContainer: document.createElement('div'),
} as unknown as Range,
rect: { top: 100, right: 300, bottom: 120, left: 200 },
key: 'test',
text: 'hello',
page: 0,
index: 0,
};
const rect: Rect = { top: 0, right: 1024, bottom: 768, left: 0 };
const result = getPosition(mockTextSelection, rect, 10);
expect(result.point).toBeDefined();
expect(result.dir).toBeDefined();
});
});
describe('isPointerInsideSelection', () => {
it('should return false when selection has no ranges', async () => {
const { isPointerInsideSelection } = await import('@/utils/sel');
const selection = {
rangeCount: 0,
} as unknown as Selection;
const ev = { clientX: 100, clientY: 100 } as PointerEvent;
expect(isPointerInsideSelection(selection, ev)).toBe(false);
});
it('should return true when pointer is inside a selection rect (with padding)', async () => {
const { isPointerInsideSelection } = await import('@/utils/sel');
const selection = {
rangeCount: 1,
getRangeAt: () => ({
getClientRects: () =>
[{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
}),
} as unknown as Selection;
// Within the rect with padding of 50
const ev = { clientX: 150, clientY: 110 } as PointerEvent;
expect(isPointerInsideSelection(selection, ev)).toBe(true);
});
it('should return true when pointer is within padding distance', async () => {
const { isPointerInsideSelection } = await import('@/utils/sel');
const selection = {
rangeCount: 1,
getRangeAt: () => ({
getClientRects: () =>
[{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
}),
} as unknown as Selection;
// Outside the rect but within 50px padding
const ev = { clientX: 60, clientY: 110 } as PointerEvent;
expect(isPointerInsideSelection(selection, ev)).toBe(true);
});
it('should return false when pointer is far outside selection', async () => {
const { isPointerInsideSelection } = await import('@/utils/sel');
const selection = {
rangeCount: 1,
getRangeAt: () => ({
getClientRects: () =>
[{ left: 100, right: 200, top: 100, bottom: 120 }] as unknown as DOMRectList,
}),
} as unknown as Selection;
// Far outside the rect
const ev = { clientX: 500, clientY: 500 } as PointerEvent;
expect(isPointerInsideSelection(selection, ev)).toBe(false);
});
it('should check all rects in a multi-rect selection', async () => {
const { isPointerInsideSelection } = await import('@/utils/sel');
const selection = {
rangeCount: 1,
getRangeAt: () => ({
getClientRects: () =>
[
{ left: 100, right: 200, top: 100, bottom: 120 },
{ left: 100, right: 300, top: 120, bottom: 140 },
] as unknown as DOMRectList,
}),
} as unknown as Selection;
// Inside the second rect
const ev = { clientX: 250, clientY: 130 } as PointerEvent;
expect(isPointerInsideSelection(selection, ev)).toBe(true);
});
});
describe('getTextFromRange', () => {
it('should extract text from a simple range', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = 'Hello <b>world</b>!';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const text = getTextFromRange(range);
expect(text).toBe('Hello world!');
document.body.removeChild(container);
});
it('should reject text from specified tags', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = 'Hello <rt>ruby</rt> world';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const text = getTextFromRange(range, ['rt']);
expect(text).not.toContain('ruby');
expect(text).toContain('Hello');
expect(text).toContain('world');
document.body.removeChild(container);
});
it('should return empty string for an empty range', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = '';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const text = getTextFromRange(range);
expect(text).toBe('');
document.body.removeChild(container);
});
it('should handle nested elements', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = '<p>First <span>nested <em>deep</em></span> end</p>';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const text = getTextFromRange(range);
expect(text).toBe('First nested deep end');
document.body.removeChild(container);
});
it('should insert a newline for <br> between adjacent text spans (PDF line wrap)', async () => {
const { getTextFromRange } = await import('@/utils/sel');
// Mirrors how pdf.js renders the text layer: each text run is its
// own <span>, and line endings are <br role="presentation">.
const container = document.createElement('div');
container.className = 'textLayer';
container.innerHTML =
'<span role="presentation">last word of line 1</span>' +
'<br role="presentation">' +
'<span role="presentation">first word of line 2</span>';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
const text = getTextFromRange(range);
// Without separating the spans the text becomes
// "last word of line 1first word of line 2" — words are glued.
expect(text).toBe('last word of line 1\nfirst word of line 2');
document.body.removeChild(container);
});
it('should insert a newline for explicit <br> in HTML content', async () => {
const { getTextFromRange } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = 'first<br>second<br>third';
document.body.appendChild(container);
const range = document.createRange();
range.selectNodeContents(container);
expect(getTextFromRange(range)).toBe('first\nsecond\nthird');
document.body.removeChild(container);
});
});
describe('snapRangeToWords', () => {
it('should snap start offset to word boundary', async () => {
const { snapRangeToWords } = await import('@/utils/sel');
const textNode = document.createTextNode('Hello world test');
const container = document.createElement('div');
container.appendChild(textNode);
document.body.appendChild(container);
const range = document.createRange();
// Start in the middle of "world" (offset 8, "r"), end at the end of "world" (offset 11)
range.setStart(textNode, 8);
range.setEnd(textNode, 11);
snapRangeToWords(range);
// Should snap start back to the beginning of "world" (offset 6)
expect(range.startOffset).toBe(6);
// End was at the end of "world" so it should stay or snap to word end
expect(range.endOffset).toBe(11);
document.body.removeChild(container);
});
it('should snap end offset to word boundary', async () => {
const { snapRangeToWords } = await import('@/utils/sel');
const textNode = document.createTextNode('Hello world test');
const container = document.createElement('div');
container.appendChild(textNode);
document.body.appendChild(container);
const range = document.createRange();
// Start at the beginning of "world" (offset 6), end in the middle of "world" (offset 8)
range.setStart(textNode, 6);
range.setEnd(textNode, 8);
snapRangeToWords(range);
expect(range.startOffset).toBe(6);
// Should snap end to the end of "world" (offset 11)
expect(range.endOffset).toBe(11);
document.body.removeChild(container);
});
it('should not snap when offset is at the start of text', async () => {
const { snapRangeToWords } = await import('@/utils/sel');
const textNode = document.createTextNode('Hello');
const container = document.createElement('div');
container.appendChild(textNode);
document.body.appendChild(container);
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, 3);
snapRangeToWords(range);
expect(range.startOffset).toBe(0);
// End should snap to end of "Hello"
expect(range.endOffset).toBe(5);
document.body.removeChild(container);
});
it('should not snap when container is not a text node', async () => {
const { snapRangeToWords } = await import('@/utils/sel');
const container = document.createElement('div');
container.innerHTML = '<span>Hello</span>';
document.body.appendChild(container);
const range = document.createRange();
range.setStart(container, 0);
range.setEnd(container, 1);
// Should not throw
snapRangeToWords(range);
expect(range.startOffset).toBe(0);
document.body.removeChild(container);
});
it('should not snap past punctuation', async () => {
const { snapRangeToWords } = await import('@/utils/sel');
const textNode = document.createTextNode('Hello, world!');
const container = document.createElement('div');
container.appendChild(textNode);
document.body.appendChild(container);
const range = document.createRange();
// Start at comma (offset 5), end at exclamation (offset 12)
range.setStart(textNode, 5);
range.setEnd(textNode, 12);
snapRangeToWords(range);
// The comma is punctuation, so start should not snap
expect(range.startOffset).toBe(5);
document.body.removeChild(container);
});
});
});