Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2025-11-23 14:10:14 +00:00
commit f83411929c
107 changed files with 23106 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.
+284
View File
@@ -0,0 +1,284 @@
"""
Tests for accelerometer-based gesture detection.
"""
import pytest
import asyncio
import json
import math
from pathlib import Path
import sys
# Test only the gesture types and math, not the full integration
# to avoid dependencies on pyWebLayout
class MockOrientationSensor:
"""Mock BMA400 accelerometer for testing"""
def __init__(self):
self.ax = 0.0
self.ay = 0.0
self.az = 9.8 # Standard gravity
async def get_acceleration(self):
"""Return mock acceleration data"""
return (self.ax, self.ay, self.az)
def set_acceleration(self, x, y, z):
"""Set acceleration for testing"""
self.ax = x
self.ay = y
self.az = z
class MockHAL:
"""Mock HAL for testing"""
def __init__(self):
self.orientation = MockOrientationSensor()
self.width = 800
self.height = 1200
@pytest.fixture
def mock_hal():
"""Create a mock HAL with accelerometer"""
return MockHAL()
@pytest.fixture
def calibration_file(tmp_path):
"""Create a temporary calibration file"""
config = {
"up_vector": {
"x": 0.0,
"y": 9.8,
"z": 0.0
},
"tilt_threshold": 0.3, # ~17 degrees
"debounce_time": 0.5
}
config_path = tmp_path / "test_accel_config.json"
with open(config_path, 'w') as f:
json.dump(config, f)
return str(config_path)
def test_load_calibration_success(mock_hal, calibration_file):
"""Test loading accelerometer calibration"""
# Create a minimal HAL-like object
class TestHAL:
def __init__(self):
self.width = 800
self.height = 1200
test_hal = TestHAL()
# Manually call the load function
result = load_accel_calibration(test_hal, calibration_file)
assert result is True
assert hasattr(test_hal, 'accel_up_vector')
assert test_hal.accel_up_vector == (0.0, 9.8, 0.0)
assert test_hal.accel_tilt_threshold == 0.3
assert test_hal.accel_debounce_time == 0.5
def load_accel_calibration(hal, config_path):
"""Helper function to load calibration (extracted from HardwareDisplayHAL)"""
import logging
logger = logging.getLogger(__name__)
config_file = Path(config_path)
if not config_file.exists():
logger.warning(f"Accelerometer calibration file not found: {config_path}")
return False
try:
with open(config_file, 'r') as f:
config = json.load(f)
# Load up vector
up = config.get("up_vector", {})
hal.accel_up_vector = (up.get("x", 0), up.get("y", 0), up.get("z", 0))
# Load thresholds
hal.accel_tilt_threshold = config.get("tilt_threshold", 0.3)
hal.accel_debounce_time = config.get("debounce_time", 0.5)
# State tracking
hal.accel_last_tilt_time = 0
return True
except Exception as e:
logger.error(f"Error loading accelerometer calibration: {e}")
return False
def test_tilt_detection_forward():
"""Test forward tilt detection"""
# Setup: device is upright (y = 9.8), then tilt forward (z increases)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 6, 6) - tilted ~45 degrees forward
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 6.0, 6.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 45 degrees (0.785 radians)
assert abs(tilt_angle - 0.785) < 0.1
# Direction: forward tilt should have positive perpendicular y component
# Actually, when tilting forward, gravity vector rotates toward +z
# The perpendicular component should reflect this
def test_tilt_detection_backward():
"""Test backward tilt detection"""
# Setup: device is upright (y = 9.8), then tilt backward (z decreases, negative)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 6, -6) - tilted ~45 degrees backward
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 6.0, -6.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 45 degrees (0.785 radians)
assert abs(tilt_angle - 0.785) < 0.1
def test_no_tilt_when_upright():
"""Test that no tilt is detected when device is upright"""
# Setup: device is perfectly upright
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: (0, 9.8, 0) - same as calibration
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 9.8, 0.0)
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 0 degrees
assert tilt_angle < 0.01
def test_small_tilt_below_threshold():
"""Test that small tilts below threshold are ignored"""
# Setup: device is slightly tilted (10 degrees)
# Calibrated up vector: (0, 9.8, 0)
# Current gravity: small tilt
angle_rad = math.radians(10)
up_vector = (0.0, 9.8, 0.0)
current_gravity = (0.0, 9.8 * math.cos(angle_rad), 9.8 * math.sin(angle_rad))
# Normalize vectors
ux, uy, uz = up_vector
u_mag = math.sqrt(ux**2 + uy**2 + uz**2)
ux, uy, uz = ux / u_mag, uy / u_mag, uz / u_mag
gx, gy, gz = current_gravity
g_mag = math.sqrt(gx**2 + gy**2 + gz**2)
gx, gy, gz = gx / g_mag, gy / g_mag, gz / g_mag
# Calculate tilt angle
dot_up = gx * ux + gy * uy + gz * uz
perp_x = gx - dot_up * ux
perp_y = gy - dot_up * uy
perp_z = gz - dot_up * uz
perp_mag = math.sqrt(perp_x**2 + perp_y**2 + perp_z**2)
tilt_angle = math.atan2(perp_mag, abs(dot_up))
# Should be approximately 10 degrees (0.174 radians)
assert abs(tilt_angle - 0.174) < 0.01
# Should be below default threshold of 0.3 rad (~17 degrees)
assert tilt_angle < 0.3
def test_gesture_types_exist():
"""Test that accelerometer gesture types are defined"""
# Simple direct test - check that gesture strings are defined
gestures = [
"tap",
"long_press",
"swipe_left",
"swipe_right",
"swipe_up",
"swipe_down",
"pinch_in",
"pinch_out",
"drag_start",
"drag_move",
"drag_end",
"tilt_forward", # Our new gestures
"tilt_backward"
]
# Verify the new gesture strings are valid
assert "tilt_forward" in gestures
assert "tilt_backward" in gestures
if __name__ == "__main__":
# Run tests
pytest.main([__file__, "-v"])
+85
View File
@@ -0,0 +1,85 @@
"""
Minimal reproduction test for backward navigation bug.
BUG: Backward navigation cannot reach block_index=0 from block_index=1.
This is a pyWebLayout issue, not a dreader-application issue.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationBug(unittest.TestCase):
"""Minimal reproduction of backward navigation bug"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_minimal_backward_navigation_bug(self):
"""
MINIMAL REPRODUCTION:
1. Start at block_index=0
2. Go forward once (to block_index=1)
3. Go backward once
4. BUG: Lands at block_index=1 instead of block_index=0
This proves backward navigation cannot reach the first block.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
# Starting position
pos_start = reader.manager.current_position.copy()
print(f"\n1. Starting at block_index={pos_start.block_index}")
self.assertEqual(pos_start.block_index, 0, "Should start at block 0")
# Go forward
reader.next_page()
pos_forward = reader.manager.current_position.copy()
print(f"2. After next_page(): block_index={pos_forward.block_index}")
self.assertEqual(pos_forward.block_index, 1, "Should be at block 1")
# Go backward
reader.previous_page()
pos_final = reader.manager.current_position.copy()
print(f"3. After previous_page(): block_index={pos_final.block_index}")
# THE BUG: This assertion will fail
print(f"\nEXPECTED: block_index=0")
print(f"ACTUAL: block_index={pos_final.block_index}")
if pos_final.block_index != 0:
print("\n❌ BUG CONFIRMED: Cannot navigate backward to block_index=0")
print(" This is a pyWebLayout bug in the previous_page() method.")
self.assertEqual(
pos_final.block_index,
0,
"BUG: Backward navigation from block 1 should return to block 0"
)
reader.close()
if __name__ == '__main__':
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""
Detailed test for backward navigation issues.
This test explores the backward navigation behavior more thoroughly
to understand if the issue is:
1. Complete failure (previous_page returns None)
2. Imprecise positioning (lands on wrong block)
3. Only occurs after resume
4. Occurs during continuous navigation
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationDetailed(unittest.TestCase):
"""Detailed backward navigation tests"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_continuous_backward_navigation_no_resume(self):
"""
Test backward navigation without closing/resuming.
This checks if the issue is specific to resume or general.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Continuous backward navigation (no resume) ===")
# Record starting position
pos0 = reader.manager.current_position.copy()
print(f"Starting position: {pos0}")
# Go forward 5 pages, recording positions
forward_positions = [pos0]
for i in range(5):
page = reader.next_page()
if page is None:
print(f"Reached end at page {i}")
break
pos = reader.manager.current_position.copy()
forward_positions.append(pos)
print(f"Forward page {i+1}: block_index={pos.block_index}")
num_forward = len(forward_positions) - 1
print(f"\nNavigated forward {num_forward} pages")
# Now go backward the same number of times
print("\n--- Going backward ---")
backward_positions = []
for i in range(num_forward):
page = reader.previous_page()
if page is None:
print(f"ERROR: previous_page() returned None at step {i+1}")
self.fail(f"Backward navigation failed at step {i+1}")
pos = reader.manager.current_position.copy()
backward_positions.append(pos)
print(f"Backward step {i+1}: block_index={pos.block_index}")
# Check final position
final_pos = reader.manager.current_position.copy()
print(f"\nFinal position: {final_pos}")
print(f"Expected (pos0): {pos0}")
if final_pos != pos0:
print(f"WARNING: Position mismatch!")
print(f" Expected block_index: {pos0.block_index}")
print(f" Actual block_index: {final_pos.block_index}")
print(f" Difference: {final_pos.block_index - pos0.block_index} blocks")
self.assertEqual(
final_pos,
pos0,
f"After {num_forward} forward and {num_forward} backward, should be at start"
)
reader.close()
def test_backward_navigation_at_start(self):
"""
Test that previous_page() behaves correctly when at the start of the book.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward navigation at start ===")
pos_start = reader.manager.current_position.copy()
print(f"At start: {pos_start}")
# Try to go back from the very first page
page = reader.previous_page()
print(f"previous_page() returned: {page is not None}")
pos_after = reader.manager.current_position.copy()
print(f"Position after previous_page(): {pos_after}")
# Should either return None or stay at same position
if page is not None:
self.assertEqual(
pos_after,
pos_start,
"If previous_page() returns a page at start, position should not change"
)
reader.close()
def test_alternating_navigation(self):
"""
Test alternating forward/backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Alternating forward/backward navigation ===")
pos0 = reader.manager.current_position.copy()
print(f"Start: block_index={pos0.block_index}")
# Go forward, back, forward, back pattern
operations = [
("forward", 1),
("backward", 1),
("forward", 2),
("backward", 1),
("forward", 1),
("backward", 2),
]
for op, count in operations:
for i in range(count):
if op == "forward":
page = reader.next_page()
else:
page = reader.previous_page()
self.assertIsNotNone(
page,
f"{op} navigation failed at iteration {i+1}"
)
pos = reader.manager.current_position.copy()
print(f"After {count}x {op}: block_index={pos.block_index}")
# We should end up at the starting position (net: +5 -4 = +1, then +1 -2 = -1, total = 0)
# Actually: +1 -1 +2 -1 +1 -2 = 0
final_pos = reader.manager.current_position.copy()
print(f"\nFinal: block_index={final_pos.block_index}")
print(f"Expected: block_index={pos0.block_index}")
self.assertEqual(
final_pos,
pos0,
"Alternating navigation should return to start"
)
reader.close()
def test_backward_then_forward(self):
"""
Test that forward navigation works correctly after backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward then forward ===")
# Go forward 3 pages
positions = [reader.manager.current_position.copy()]
for i in range(3):
reader.next_page()
positions.append(reader.manager.current_position.copy())
print(f"Forward positions: {[p.block_index for p in positions]}")
# Go back 3 pages
for i in range(3):
reader.previous_page()
pos_after_back = reader.manager.current_position.copy()
print(f"After going back: block_index={pos_after_back.block_index}")
# Now go forward 3 pages again
for i in range(3):
reader.next_page()
final_pos = reader.manager.current_position.copy()
print(f"After going forward again: block_index={final_pos.block_index}")
print(f"Expected: block_index={positions[3].block_index}")
self.assertEqual(
final_pos,
positions[3],
"Forward after backward should reach same position"
)
reader.close()
if __name__ == '__main__':
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
"""
Test backward navigation after resuming from a saved position.
This test specifically checks if backward navigation works correctly
after opening an epub, navigating forward, closing it, then resuming
and attempting to navigate backward.
This may reveal issues with pyWebLayout's backward navigation handling.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
import numpy as np
from PIL import Image
from dreader.application import EbookReader
class TestBackwardNavigationAfterResume(unittest.TestCase):
"""Test backward navigation behavior after resume"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
"""
Check if two PIL Images are pixel-perfect identical.
"""
if img1 is None or img2 is None:
return False
if img1.size != img2.size:
return False
arr1 = np.array(img1)
arr2 = np.array(img2)
return np.array_equal(arr1, arr2)
def test_backward_navigation_after_resume(self):
"""
Test that backward navigation works after closing and resuming.
Steps:
1. Open EPUB
2. Navigate forward 3 pages
3. Save positions and pages
4. Close reader
5. Open new reader (resume)
6. Try to navigate backward
7. Verify we can reach previous pages
"""
# Phase 1: Initial session - navigate forward
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0 # Disable buffering for consistent testing
)
success = reader1.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
# Capture initial page
page0 = reader1.get_current_page()
self.assertIsNotNone(page0, "Initial page should not be None")
pos0 = reader1.manager.current_position.copy()
print(f"\nInitial position: {pos0}")
# Navigate forward 3 pages, capturing each page
pages = [page0]
positions = [pos0]
for i in range(3):
page = reader1.next_page()
self.assertIsNotNone(page, f"Page {i+1} should not be None")
pages.append(page)
positions.append(reader1.manager.current_position.copy())
print(f"Forward page {i+1} position: {positions[-1]}")
# We should now be at page 3 (0-indexed)
self.assertEqual(len(pages), 4, "Should have 4 pages total (0-3)")
# Save the current position before closing
final_position = reader1.manager.current_position.copy()
print(f"Final position before close: {final_position}")
# Close reader (this should save the position)
reader1.close()
# Phase 2: Resume session - navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader2.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB on resume")
# Verify we resumed at the correct position
resumed_position = reader2.manager.current_position.copy()
print(f"Resumed at position: {resumed_position}")
self.assertEqual(
resumed_position,
final_position,
"Should resume at the last saved position"
)
# Get the current page (should match page 3)
resumed_page = reader2.get_current_page()
self.assertIsNotNone(resumed_page, "Resumed page should not be None")
# Now try to navigate backward
print("\nAttempting backward navigation...")
backward_pages = []
backward_positions = []
# Try to go back 3 times
for i in range(3):
prev_page = reader2.previous_page()
print(f"Backward step {i+1}: page={'Not None' if prev_page else 'None'}")
if prev_page is None:
print(f"WARNING: previous_page() returned None at step {i+1}")
# This is the bug we're testing for!
self.fail(f"Backward navigation failed at step {i+1}: previous_page() returned None")
backward_pages.append(prev_page)
backward_positions.append(reader2.manager.current_position.copy())
print(f" Position after backward: {backward_positions[-1]}")
# We should have successfully gone back 3 pages
self.assertEqual(len(backward_pages), 3, "Should have navigated back 3 pages")
# Verify final position matches original position
final_backward_position = reader2.manager.current_position.copy()
print(f"\nFinal position after backward navigation: {final_backward_position}")
print(f"Original position (page 0): {pos0}")
self.assertEqual(
final_backward_position,
pos0,
"After going forward 3 and back 3, should be at initial position"
)
# Verify the page content matches
final_page = reader2.get_current_page()
self.assertTrue(
self.compare_images(page0, final_page),
"Final page should match initial page after forward/backward navigation"
)
reader2.close()
print("\n✓ Test passed: Backward navigation works correctly after resume")
def test_backward_navigation_single_step(self):
"""
Simplified test: Open, go forward 1 page, close, resume, go back 1 page.
This is a minimal reproduction case.
"""
# Session 1: Navigate forward one page
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader1.load_epub(self.epub_path)
page0 = reader1.get_current_page()
pos0 = reader1.manager.current_position.copy()
page1 = reader1.next_page()
self.assertIsNotNone(page1, "Should be able to navigate forward")
pos1 = reader1.manager.current_position.copy()
reader1.close()
# Session 2: Resume and navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader2.load_epub(self.epub_path)
# Verify we're at page 1
self.assertEqual(
reader2.manager.current_position,
pos1,
"Should resume at page 1"
)
# Try to go back
prev_page = reader2.previous_page()
# This is the critical assertion - if this fails, backward nav is broken
self.assertIsNotNone(
prev_page,
"CRITICAL: previous_page() returned None after resume - this indicates a pyWebLayout bug"
)
# Verify we're back at page 0
final_pos = reader2.manager.current_position.copy()
self.assertEqual(
final_pos,
pos0,
"Should be back at initial position"
)
reader2.close()
if __name__ == '__main__':
unittest.main()
+589
View File
@@ -0,0 +1,589 @@
"""
Comprehensive tests for boot recovery and resume functionality.
Tests cover:
- Saving state when closing reader
- Resuming from saved state with a new reader instance
- Restoring reading position (page/chapter)
- Restoring settings (font size, spacing, etc.)
- Restoring bookmarks
- Handling state across multiple books
- Error recovery (corrupt state, missing books)
- Bookmark-based position restoration
"""
import unittest
import tempfile
import shutil
import json
import asyncio
from pathlib import Path
from typing import Dict, Any
from dreader.application import EbookReader
from dreader.state import StateManager, AppState, BookState, Settings, EreaderMode, OverlayState
class TestBootRecovery(unittest.TestCase):
"""Test application state persistence and recovery across reader instances"""
def setUp(self):
"""Set up test environment with temporary directories"""
self.temp_dir = tempfile.mkdtemp()
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
self.highlights_dir = Path(self.temp_dir) / "highlights"
self.state_file = Path(self.temp_dir) / "state.json"
self.bookmarks_dir.mkdir(exist_ok=True)
self.highlights_dir.mkdir(exist_ok=True)
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_save_and_restore_reading_position(self):
"""Test saving current position and restoring it in a new reader"""
# Create first reader instance
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load book and navigate to middle
reader1.load_epub(self.epub_path)
# Navigate forward several pages
for _ in range(5):
reader1.next_page()
# Get position before saving
original_position = reader1.get_position_info()
original_progress = reader1.get_reading_progress()
# Save position using special auto-resume bookmark
reader1.save_position("__auto_resume__")
# Close reader
reader1.close()
# Create new reader instance
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load same book
reader2.load_epub(self.epub_path)
# Restore position
success = reader2.load_position("__auto_resume__")
self.assertTrue(success, "Failed to load auto-resume position")
# Verify position matches
restored_position = reader2.get_position_info()
restored_progress = reader2.get_reading_progress()
# Compare positions using the position dict
self.assertEqual(original_position.get('position'), restored_position.get('position'),
f"Position mismatch: {original_position} vs {restored_position}")
self.assertAlmostEqual(original_progress, restored_progress,
places=2, msg="Progress percentage mismatch")
reader2.close()
def test_save_and_restore_settings(self):
"""Test saving settings and restoring them in a new reader"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Change settings
reader1.increase_font_size()
reader1.increase_font_size()
reader1.set_line_spacing(10)
reader1.set_inter_block_spacing(25)
# Get settings
original_font_scale = reader1.base_font_scale
original_line_spacing = reader1.page_style.line_spacing
original_inter_block = reader1.page_style.inter_block_spacing
# Create state manager and save settings
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.update_settings({
'font_scale': original_font_scale,
'line_spacing': original_line_spacing,
'inter_block_spacing': original_inter_block
})
state_manager.save_state(force=True)
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Load state and apply settings
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state_manager2.load_state()
settings_dict = state_manager2.get_settings().to_dict()
reader2.apply_settings(settings_dict)
# Verify settings match
self.assertAlmostEqual(original_font_scale, reader2.base_font_scale, places=2,
msg="Font scale mismatch")
self.assertEqual(original_line_spacing, reader2.page_style.line_spacing,
"Line spacing mismatch")
self.assertEqual(original_inter_block, reader2.page_style.inter_block_spacing,
"Inter-block spacing mismatch")
reader2.close()
def test_save_and_restore_bookmarks(self):
"""Test that bookmarks persist across reader instances"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate and create bookmarks
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark1")
reader1.next_page()
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark2")
# Get bookmark list
original_bookmarks = reader1.list_saved_positions()
self.assertGreater(len(original_bookmarks), 0, "No bookmarks saved")
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Check bookmarks exist
restored_bookmarks = reader2.list_saved_positions()
self.assertIn("bookmark1", restored_bookmarks, "bookmark1 not found")
self.assertIn("bookmark2", restored_bookmarks, "bookmark2 not found")
# Test loading each bookmark
success1 = reader2.load_position("bookmark1")
self.assertTrue(success1, "Failed to load bookmark1")
success2 = reader2.load_position("bookmark2")
self.assertTrue(success2, "Failed to load bookmark2")
reader2.close()
def test_full_state_persistence_workflow(self):
"""Test complete workflow: read, change settings, save, close, restore"""
# Session 1: Initial reading session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Simulate reading session
for _ in range(3):
reader1.next_page()
reader1.increase_font_size()
reader1.set_line_spacing(8)
# Save everything
reader1.save_position("__auto_resume__")
reader1.save_position("my_bookmark")
session1_position = reader1.get_position_info()
session1_progress = reader1.get_reading_progress()
session1_font = reader1.base_font_scale
session1_spacing = reader1.page_style.line_spacing
# Save state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path=self.epub_path,
title=reader1.book_title or "Test Book",
author=reader1.book_author or "Test Author"
))
state_manager.update_settings({
'font_scale': session1_font,
'line_spacing': session1_spacing
})
state_manager.save_state(force=True)
reader1.close()
# Session 2: Resume reading
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
loaded_state = state_manager2.load_state()
# Verify state loaded
self.assertIsNotNone(loaded_state.current_book, "No current book in state")
self.assertEqual(loaded_state.current_book.path, self.epub_path,
"Book path mismatch")
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(loaded_state.current_book.path)
reader2.apply_settings(loaded_state.settings.to_dict())
reader2.load_position("__auto_resume__")
# Verify restoration
session2_position = reader2.get_position_info()
session2_progress = reader2.get_reading_progress()
self.assertEqual(session1_position.get('position'), session2_position.get('position'),
"Position not restored correctly")
self.assertAlmostEqual(session1_progress, session2_progress, places=2,
msg="Progress not restored correctly")
self.assertAlmostEqual(session1_font, reader2.base_font_scale, places=2,
msg="Font scale not restored correctly")
self.assertEqual(session1_spacing, reader2.page_style.line_spacing,
"Line spacing not restored correctly")
# Verify bookmark exists
bookmarks = reader2.list_saved_positions()
self.assertIn("my_bookmark", bookmarks, "Bookmark lost after restart")
reader2.close()
def test_multiple_books_separate_state(self):
"""Test that different books maintain separate positions and bookmarks"""
epub_path = self.epub_path
# Book 1 - First session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(epub_path)
for _ in range(3):
reader1.next_page()
reader1.save_position("__auto_resume__")
book1_position = reader1.get_position_info()
book1_progress = reader1.get_reading_progress()
book1_doc_id = reader1.document_id
reader1.close()
# Book 1 - Second session (simulate reopening)
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the same position
book1_position_restored = reader2.get_position_info()
book1_progress_restored = reader2.get_reading_progress()
self.assertEqual(book1_position.get('position'), book1_position_restored.get('position'),
"Book position not preserved across sessions")
self.assertAlmostEqual(book1_progress, book1_progress_restored, places=2,
msg="Book progress not preserved")
# Now navigate further and save again
for _ in range(2):
reader2.next_page()
reader2.save_position("__auto_resume__")
book1_position_updated = reader2.get_position_info()
book1_progress_updated = reader2.get_reading_progress()
reader2.close()
# Book 1 - Third session, verify updated position
reader3 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader3.load_epub(epub_path)
reader3.load_position("__auto_resume__")
book1_position_final = reader3.get_position_info()
book1_progress_final = reader3.get_reading_progress()
self.assertEqual(book1_position_updated.get('position'), book1_position_final.get('position'),
"Updated position not preserved")
self.assertAlmostEqual(book1_progress_updated, book1_progress_final, places=2,
msg="Updated progress not preserved")
reader3.close()
def test_corrupt_state_file_recovery(self):
"""Test graceful handling of corrupt state file"""
# Create corrupt state file
with open(self.state_file, 'w') as f:
f.write("{ corrupt json content ][[ }")
# Try to load state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should return default state, not crash
self.assertIsNotNone(state)
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
# Verify backup was created
backup_file = self.state_file.with_suffix('.json.backup')
self.assertTrue(backup_file.exists(), "Backup file not created for corrupt state")
def test_missing_book_in_state(self):
"""Test handling when saved state references a missing book"""
# Create valid state pointing to non-existent book
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path="/nonexistent/book.epub",
title="Missing Book",
author="Ghost Author"
))
state_manager.save_state(force=True)
# Load state
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager2.load_state()
# State loads successfully
self.assertIsNotNone(state.current_book)
self.assertEqual(state.current_book.path, "/nonexistent/book.epub")
# But trying to load the book should fail gracefully
reader = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
success = reader.load_epub(state.current_book.path)
self.assertFalse(success, "Should fail to load non-existent book")
self.assertFalse(reader.is_loaded(), "Reader should not be in loaded state")
reader.close()
def test_no_state_file_cold_start(self):
"""Test first boot with no existing state file"""
# Ensure no state file exists
if self.state_file.exists():
self.state_file.unlink()
# Create state manager
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should get default state
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
self.assertEqual(state.overlay, OverlayState.NONE)
self.assertEqual(state.settings.font_scale, 1.0)
# Should be able to save new state
success = state_manager.save_state(force=True)
self.assertTrue(success, "Failed to save initial state")
self.assertTrue(self.state_file.exists(), "State file not created")
def test_position_survives_settings_change(self):
"""Test that position is preserved when settings change"""
# Create reader and navigate
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate to specific position
for _ in range(4):
reader1.next_page()
reader1.save_position("__auto_resume__")
position1_info = reader1.get_position_info()
# Change font size (which re-paginates)
reader1.increase_font_size()
reader1.increase_font_size()
# Position might change due to repagination, but logical position is preserved
# Save again
reader1.save_position("__auto_resume__")
position_after_resize_info = reader1.get_position_info()
position_after_resize_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader with same settings
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Apply same font size
reader2.increase_font_size()
reader2.increase_font_size()
# Load position
reader2.load_position("__auto_resume__")
position2_info = reader2.get_position_info()
position2_progress = reader2.get_reading_progress()
# Should match the position after resize, not the original
self.assertEqual(position_after_resize_info.get('position'), position2_info.get('position'),
"Position not preserved after font size change")
self.assertAlmostEqual(position_after_resize_progress, position2_progress, places=2,
msg="Progress not preserved after font size change")
reader2.close()
def test_chapter_position_restoration(self):
"""Test that chapter context is preserved across sessions"""
# Create reader and jump to specific chapter
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Get chapters
chapters = reader1.get_chapters()
if len(chapters) < 2:
self.skipTest("Test EPUB needs at least 2 chapters")
# Jump to second chapter
_, chapter_idx = chapters[1]
reader1.jump_to_chapter(chapter_idx)
# Navigate a bit within the chapter
reader1.next_page()
# Save position
reader1.save_position("__auto_resume__")
chapter1_position = reader1.get_position_info()
chapter1_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the right position
chapter2_position = reader2.get_position_info()
chapter2_progress = reader2.get_reading_progress()
self.assertEqual(chapter1_position.get('position'), chapter2_position.get('position'),
"Chapter position not restored correctly")
self.assertAlmostEqual(chapter1_progress, chapter2_progress, places=2,
msg="Chapter progress not restored correctly")
reader2.close()
class TestStateManagerAsync(unittest.TestCase):
"""Test StateManager async functionality"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.state_file = Path(self.temp_dir) / "state.json"
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_async_auto_save(self):
"""Test that async auto-save works"""
async def test_auto_save():
# Create state manager with short interval
state_manager = StateManager(str(self.state_file), auto_save_interval=1)
# Start auto-save
state_manager.start_auto_save()
# Make a change
state_manager.set_mode(EreaderMode.READING)
# Wait for auto-save to trigger
await asyncio.sleep(1.5)
# Stop auto-save
await state_manager.stop_auto_save(save_final=True)
# Verify file was saved
self.assertTrue(self.state_file.exists(), "State file not created")
# Load and verify
with open(self.state_file) as f:
data = json.load(f)
self.assertEqual(data['mode'], 'reading')
# Run async test
asyncio.run(test_auto_save())
def test_async_save_with_lock(self):
"""Test that async saves are thread-safe"""
async def test_concurrent_saves():
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
# Make multiple concurrent saves
tasks = []
for i in range(10):
state_manager.update_setting('brightness', i)
tasks.append(state_manager.save_state_async(force=True))
# Wait for all saves
results = await asyncio.gather(*tasks)
# All should succeed
self.assertTrue(all(results), "Some saves failed")
# File should exist and be valid
self.assertTrue(self.state_file.exists())
# Load and verify (should have last value)
with open(self.state_file) as f:
data = json.load(f)
# Brightness should be set (exact value depends on race, but should be 0-9)
self.assertIn(data['settings']['brightness'], range(10))
asyncio.run(test_concurrent_saves())
if __name__ == '__main__':
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test that images render correctly in EPUB files.
This test verifies that:
1. All images in the EPUB are loaded with correct dimensions
2. Images can be navigated to without errors
3. Pages with images render successfully
4. The rendered pages contain actual image content (not blank)
"""
import pytest
from dreader.application import EbookReader
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image
import numpy as np
def test_epub_images():
"""Test that EPUB images render correctly."""
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load EPUB
epub_path = "tests/data/library-epub/pg11-images-3.epub"
success = reader.load_epub(epub_path)
assert success, "Failed to load EPUB"
assert reader.book_title == "Alice's Adventures in Wonderland"
# Check that images were parsed
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"Expected at least 1 image, found {len(images)}"
# Check that all images have dimensions set
for img in images:
assert img.width is not None, f"Image {img.source} has no width"
assert img.height is not None, f"Image {img.source} has no height"
assert img.width > 0, f"Image {img.source} has invalid width: {img.width}"
assert img.height > 0, f"Image {img.source} has invalid height: {img.height}"
# Check that image is loaded into memory
assert hasattr(img, '_loaded_image'), f"Image {img.source} not loaded"
assert img._loaded_image is not None, f"Image {img.source} _loaded_image is None"
# Test navigation through first 15 pages (which should include all images)
for page_num in range(15):
page_img = reader.get_current_page()
assert page_img is not None, f"Page {page_num + 1} failed to render"
assert isinstance(page_img, Image.Image), f"Page {page_num + 1} is not a PIL Image"
assert page_img.size == (800, 1200), f"Page {page_num + 1} has wrong size: {page_img.size}"
# Check that page has some non-white content
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
assert non_white_pixels > 100, f"Page {page_num + 1} appears to be blank (only {non_white_pixels} non-white pixels)"
# Navigate to next page
if page_num < 14:
next_result = reader.next_page()
if next_result is None:
# It's OK to reach end of book early
break
def test_cover_image():
"""Specifically test that the cover image renders."""
reader = EbookReader(page_size=(800, 1200))
reader.load_epub("tests/data/library-epub/pg11-images-3.epub")
# The first page should have the cover image
page_img = reader.get_current_page()
assert page_img is not None, "Cover page failed to render"
# Save for visual inspection
output_path = "/tmp/epub_cover_test.png"
page_img.save(output_path)
# Check that it has significant content (the cover image)
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
# The cover page should have substantial content
assert non_white_pixels > 10000, f"Cover page has too few non-white pixels: {non_white_pixels}"
def test_multiple_epub_images():
"""Test images across multiple EPUB files."""
epub_files = [
("tests/data/library-epub/pg11-images-3.epub", "Alice's Adventures in Wonderland"),
("tests/data/library-epub/pg16328-images-3.epub", "Beowulf: An Anglo-Saxon Epic Poem"),
("tests/data/library-epub/pg5200-images-3.epub", "Metamorphosis"),
]
for epub_path, expected_title in epub_files:
reader = EbookReader(page_size=(800, 1200))
success = reader.load_epub(epub_path)
assert success, f"Failed to load {epub_path}"
assert reader.book_title == expected_title
# Check that at least one image exists
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"{epub_path} should have at least 1 image"
# Check first image is valid
img = images[0]
assert img.width > 0 and img.height > 0, f"Invalid dimensions in {epub_path}"
if __name__ == "__main__":
# Run tests directly
print("Testing EPUB images...")
print("\n1. Testing all images load and render...")
test_epub_images()
print("✓ PASSED")
print("\n2. Testing cover image...")
test_cover_image()
print("✓ PASSED")
print("\n3. Testing multiple EPUB images...")
test_multiple_epub_images()
print("✓ PASSED")
print("\n✓ All tests passed!")
+832
View File
@@ -0,0 +1,832 @@
"""
Comprehensive tests for the EbookReader application interface.
Tests cover:
- EPUB loading and initialization
- Navigation (forward, backward, boundaries)
- Font scaling and styling
- Chapter navigation
- Position management (bookmarks)
- Information retrieval
- File operations
- Error handling
- Context manager
- Integration scenarios
"""
import unittest
import tempfile
import shutil
from pathlib import Path
import numpy as np
from PIL import Image
import os
from dreader.application import EbookReader, create_ebook_reader
class TestEbookReaderInitialization(unittest.TestCase):
"""Test EbookReader creation and EPUB loading"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_create_reader_with_defaults(self):
"""Test creating reader with default settings"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
self.assertEqual(reader.page_size, (800, 1000))
self.assertEqual(reader.base_font_scale, 1.0)
self.assertIsNone(reader.manager)
self.assertFalse(reader.is_loaded())
reader.close()
def test_create_reader_with_custom_settings(self):
"""Test creating reader with custom settings"""
reader = EbookReader(
page_size=(600, 800),
margin=50,
background_color=(240, 240, 240),
line_spacing=10,
inter_block_spacing=20,
bookmarks_dir=self.temp_dir,
buffer_size=3
)
self.assertEqual(reader.page_size, (600, 800))
self.assertEqual(reader.page_style.line_spacing, 10)
self.assertEqual(reader.page_style.inter_block_spacing, 20)
self.assertEqual(reader.buffer_size, 3)
reader.close()
def test_load_valid_epub(self):
"""Test loading a valid EPUB file"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub(self.epub_path)
self.assertTrue(success)
self.assertTrue(reader.is_loaded())
self.assertIsNotNone(reader.manager)
self.assertIsNotNone(reader.blocks)
self.assertIsNotNone(reader.document_id)
self.assertIsNotNone(reader.book_title)
self.assertIsNotNone(reader.book_author)
reader.close()
def test_load_nonexistent_epub(self):
"""Test loading a non-existent EPUB file"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub("nonexistent.epub")
self.assertFalse(success)
self.assertFalse(reader.is_loaded())
reader.close()
def test_load_invalid_epub(self):
"""Test loading an invalid file as EPUB"""
# Create a temporary invalid file
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
with open(invalid_path, 'w') as f:
f.write("This is not a valid EPUB file")
reader = EbookReader(bookmarks_dir=self.temp_dir)
success = reader.load_epub(invalid_path)
self.assertFalse(success)
self.assertFalse(reader.is_loaded())
reader.close()
def test_convenience_function(self):
"""Test create_ebook_reader convenience function"""
reader = create_ebook_reader(
page_size=(700, 900),
bookmarks_dir=self.temp_dir
)
self.assertIsInstance(reader, EbookReader)
self.assertEqual(reader.page_size, (700, 900))
reader.close()
class TestEbookReaderFontScaling(unittest.TestCase):
"""Test font size control"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0 # Disable buffering for tests
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_set_font_size(self):
"""Test setting font size with arbitrary scale"""
page = self.reader.set_font_size(1.5)
self.assertIsNotNone(page)
self.assertEqual(self.reader.get_font_size(), 1.5)
def test_increase_font_size(self):
"""Test increasing font size by one step"""
initial_size = self.reader.get_font_size()
page = self.reader.increase_font_size()
self.assertIsNotNone(page)
self.assertEqual(self.reader.get_font_size(), initial_size + 0.1)
def test_decrease_font_size(self):
"""Test decreasing font size by one step"""
self.reader.set_font_size(1.5)
page = self.reader.decrease_font_size()
self.assertIsNotNone(page)
self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5)
def test_font_size_bounds_clamping(self):
"""Test that font size is clamped between 0.5x and 3.0x"""
# Test upper bound
self.reader.set_font_size(5.0)
self.assertEqual(self.reader.get_font_size(), 3.0)
# Test lower bound
self.reader.set_font_size(0.1)
self.assertEqual(self.reader.get_font_size(), 0.5)
def test_get_font_size(self):
"""Test getting current font size"""
self.assertEqual(self.reader.get_font_size(), 1.0)
self.reader.set_font_size(2.0)
self.assertEqual(self.reader.get_font_size(), 2.0)
def test_font_scale_with_navigation(self):
"""Test that font scale persists across page navigation"""
self.reader.set_font_size(1.5)
initial_font_size = self.reader.get_font_size()
# Navigate forward
self.reader.next_page()
# Font size should be preserved
self.assertEqual(self.reader.get_font_size(), initial_font_size)
class TestEbookReaderSpacing(unittest.TestCase):
"""Test line and block spacing"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_set_line_spacing(self):
"""Test setting line spacing"""
page = self.reader.set_line_spacing(10)
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.line_spacing, 10)
def test_set_inter_block_spacing(self):
"""Test setting inter-block spacing"""
page = self.reader.set_inter_block_spacing(25)
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.inter_block_spacing, 25)
def test_spacing_with_navigation(self):
"""Test that spacing changes affect rendering after navigation"""
self.reader.set_line_spacing(15)
page = self.reader.next_page()
self.assertIsNotNone(page)
self.assertEqual(self.reader.page_style.line_spacing, 15)
def test_spacing_position_preservation(self):
"""Test that changing spacing preserves reading position"""
# Navigate to a specific position
for _ in range(3):
self.reader.next_page()
position_before = self.reader.manager.current_position.copy()
# Change spacing
self.reader.set_line_spacing(12)
position_after = self.reader.manager.current_position
# Position should be preserved
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
self.assertEqual(position_before.block_index, position_after.block_index)
class TestEbookReaderChapterNavigation(unittest.TestCase):
"""Test chapter navigation features"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_get_chapters(self):
"""Test getting list of chapters"""
chapters = self.reader.get_chapters()
self.assertIsInstance(chapters, list)
if len(chapters) > 0:
# Each chapter should be a tuple (title, index)
self.assertIsInstance(chapters[0], tuple)
self.assertEqual(len(chapters[0]), 2)
def test_get_chapter_positions(self):
"""Test getting chapter positions"""
positions = self.reader.get_chapter_positions()
self.assertIsInstance(positions, list)
if len(positions) > 0:
# Each item should be (title, RenderingPosition)
self.assertIsInstance(positions[0], tuple)
self.assertEqual(len(positions[0]), 2)
def test_jump_to_chapter_by_index(self):
"""Test jumping to chapter by index"""
chapters = self.reader.get_chapters()
if len(chapters) > 0:
page = self.reader.jump_to_chapter(0)
self.assertIsNotNone(page)
def test_jump_to_chapter_by_name(self):
"""Test jumping to chapter by name"""
chapters = self.reader.get_chapters()
if len(chapters) > 0:
chapter_title = chapters[0][0]
page = self.reader.jump_to_chapter(chapter_title)
self.assertIsNotNone(page)
def test_jump_to_invalid_chapter_index(self):
"""Test jumping to invalid chapter index"""
page = self.reader.jump_to_chapter(9999)
self.assertIsNone(page)
def test_jump_to_invalid_chapter_name(self):
"""Test jumping to non-existent chapter name"""
page = self.reader.jump_to_chapter("Non-Existent Chapter")
self.assertIsNone(page)
class TestEbookReaderInformation(unittest.TestCase):
"""Test information retrieval methods"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_get_position_info(self):
"""Test getting detailed position information"""
info = self.reader.get_position_info()
self.assertIsInstance(info, dict)
self.assertIn('position', info)
self.assertIn('chapter', info)
self.assertIn('progress', info)
self.assertIn('font_scale', info)
self.assertIn('book_title', info)
self.assertIn('book_author', info)
def test_get_reading_progress(self):
"""Test getting reading progress as percentage"""
progress = self.reader.get_reading_progress()
self.assertIsInstance(progress, float)
self.assertGreaterEqual(progress, 0.0)
self.assertLessEqual(progress, 1.0)
# Progress should increase after navigation
initial_progress = progress
for _ in range(5):
self.reader.next_page()
new_progress = self.reader.get_reading_progress()
self.assertGreater(new_progress, initial_progress)
def test_get_current_chapter_info(self):
"""Test getting current chapter information"""
info = self.reader.get_current_chapter_info()
# May be None if no chapters
if info is not None:
self.assertIsInstance(info, dict)
self.assertIn('title', info)
self.assertIn('level', info)
self.assertIn('block_index', info)
def test_get_book_info_complete(self):
"""Test getting complete book information"""
info = self.reader.get_book_info()
self.assertIsInstance(info, dict)
self.assertIn('title', info)
self.assertIn('author', info)
self.assertIn('document_id', info)
self.assertIn('total_blocks', info)
self.assertIn('total_chapters', info)
self.assertIn('page_size', info)
self.assertIn('font_scale', info)
self.assertGreater(info['total_blocks'], 0)
self.assertEqual(info['page_size'], self.reader.page_size)
class TestEbookReaderFileOperations(unittest.TestCase):
"""Test file I/O operations"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_render_to_file_png(self):
"""Test saving current page as PNG"""
output_path = os.path.join(self.temp_dir, "page.png")
success = self.reader.render_to_file(output_path)
self.assertTrue(success)
self.assertTrue(os.path.exists(output_path))
# Verify it's a valid image
img = Image.open(output_path)
self.assertEqual(img.size, self.reader.page_size)
def test_render_to_file_jpg(self):
"""Test saving current page as JPEG"""
output_path = os.path.join(self.temp_dir, "page.jpg")
# Get the page image and convert to RGB (JPEG doesn't support RGBA)
page_img = self.reader.get_current_page()
if page_img.mode == 'RGBA':
page_img = page_img.convert('RGB')
# Save manually since render_to_file might not handle conversion
try:
page_img.save(output_path)
success = True
except Exception:
success = False
self.assertTrue(success)
self.assertTrue(os.path.exists(output_path))
def test_render_to_invalid_path(self):
"""Test saving to invalid path"""
invalid_path = "/nonexistent/directory/page.png"
success = self.reader.render_to_file(invalid_path)
self.assertFalse(success)
class TestEbookReaderContextManager(unittest.TestCase):
"""Test context manager and cleanup"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_context_manager_usage(self):
"""Test using EbookReader as context manager"""
with EbookReader(bookmarks_dir=self.temp_dir) as reader:
success = reader.load_epub(self.epub_path)
self.assertTrue(success)
page = reader.get_current_page()
self.assertIsNotNone(page)
# After exiting context, manager should be cleaned up
self.assertIsNone(reader.manager)
def test_close_method(self):
"""Test explicit close method"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
reader.load_epub(self.epub_path)
self.assertIsNotNone(reader.manager)
reader.close()
self.assertIsNone(reader.manager)
def test_operations_after_close(self):
"""Test that operations fail gracefully after close"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
reader.load_epub(self.epub_path)
reader.close()
# These should all return None or empty
self.assertIsNone(reader.get_current_page())
self.assertIsNone(reader.next_page())
self.assertIsNone(reader.previous_page())
self.assertEqual(reader.get_chapters(), [])
class TestEbookReaderErrorHandling(unittest.TestCase):
"""Test error handling and edge cases"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_operations_without_loaded_book(self):
"""Test that operations handle unloaded state gracefully"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
# All these should return None or empty/False
self.assertIsNone(reader.get_current_page())
self.assertIsNone(reader.next_page())
self.assertIsNone(reader.previous_page())
self.assertFalse(reader.save_position("test"))
self.assertIsNone(reader.load_position("test"))
self.assertEqual(reader.list_saved_positions(), [])
self.assertFalse(reader.delete_position("test"))
self.assertEqual(reader.get_chapters(), [])
self.assertIsNone(reader.jump_to_chapter(0))
self.assertIsNone(reader.set_font_size(1.5))
self.assertEqual(reader.get_reading_progress(), 0.0)
self.assertIsNone(reader.get_current_chapter_info())
reader.close()
def test_is_loaded(self):
"""Test is_loaded method"""
reader = EbookReader(bookmarks_dir=self.temp_dir)
self.assertFalse(reader.is_loaded())
reader.load_epub(self.epub_path)
self.assertTrue(reader.is_loaded())
reader.close()
class TestEbookReaderIntegration(unittest.TestCase):
"""Test complex integration scenarios"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_font_scaling_preserves_position(self):
"""Test that changing font scale preserves reading position"""
# Navigate to a specific position
for _ in range(3):
self.reader.next_page()
position_before = self.reader.manager.current_position.copy()
# Change font size
self.reader.set_font_size(1.5)
position_after = self.reader.manager.current_position
# Position should be preserved
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
self.assertEqual(position_before.block_index, position_after.block_index)
def test_styling_with_bookmarks(self):
"""Test that bookmarks work correctly across styling changes"""
# Navigate and save position
for _ in range(5):
self.reader.next_page()
self.reader.save_position("test_bookmark")
# Change styling
self.reader.set_font_size(1.5)
self.reader.set_line_spacing(12)
# Navigate away
for _ in range(5):
self.reader.next_page()
# Jump back to bookmark
page = self.reader.load_position("test_bookmark")
self.assertIsNotNone(page)
# Cleanup
self.reader.delete_position("test_bookmark")
def test_chapter_navigation_after_font_change(self):
"""Test chapter navigation after changing font size"""
self.reader.set_font_size(2.0)
chapters = self.reader.get_chapters()
if len(chapters) > 0:
page = self.reader.jump_to_chapter(0)
self.assertIsNotNone(page)
class TestEbookReaderNavigation(unittest.TestCase):
"""Test EbookReader navigation functionality (existing tests)"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
"""
Check if two PIL Images are pixel-perfect identical.
"""
if img1 is None or img2 is None:
return False
if img1.size != img2.size:
return False
arr1 = np.array(img1)
arr2 = np.array(img2)
return np.array_equal(arr1, arr2)
def test_bidirectional_navigation_20_pages(self):
"""
Test that navigating forward 20 pages and then backward 20 pages
produces identical page renderings for the first page.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
self.assertTrue(reader.is_loaded(), "Reader should be loaded")
initial_page = reader.get_current_page()
self.assertIsNotNone(initial_page, "Initial page should not be None")
initial_position = reader.manager.current_position.copy()
forward_pages = [initial_page]
forward_positions = [initial_position]
pages_to_navigate = 20
for i in range(pages_to_navigate):
page = reader.next_page()
if page is None:
break
forward_pages.append(page)
forward_positions.append(reader.manager.current_position.copy())
actual_pages_navigated = len(forward_pages) - 1
backward_pages = []
for i in range(len(forward_positions) - 1, -1, -1):
position = forward_positions[i]
page_obj = reader.manager.jump_to_position(position)
page_img = page_obj.render()
backward_pages.append(page_img)
final_page = backward_pages[-1]
self.assertTrue(
self.compare_images(initial_page, final_page),
"First page should be identical after forward/backward navigation"
)
reader.close()
def test_navigation_at_boundaries(self):
"""Test navigation behavior at document boundaries."""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
# Try to go backward from first page
page = reader.previous_page()
# Should return None or stay on same page
# Navigate forward until end
pages_forward = 0
max_pages = 100
while pages_forward < max_pages:
page = reader.next_page()
if page is None:
break
pages_forward += 1
# Try to go forward from last page
page = reader.next_page()
self.assertIsNone(page, "Should return None at end of document")
reader.close()
class TestEbookReaderPositionManagement(unittest.TestCase):
"""Test position tracking and bookmark features"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.reader = EbookReader(
bookmarks_dir=self.temp_dir,
buffer_size=0
)
self.reader.load_epub(self.epub_path)
def tearDown(self):
"""Clean up test environment"""
self.reader.close()
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_position_save_and_load(self):
"""Test saving and loading positions"""
# Navigate to a position
for _ in range(3):
self.reader.next_page()
# Save position
success = self.reader.save_position("test_pos")
self.assertTrue(success)
# Navigate away
for _ in range(5):
self.reader.next_page()
# Load saved position
page = self.reader.load_position("test_pos")
self.assertIsNotNone(page)
def test_list_saved_positions(self):
"""Test listing saved positions"""
self.reader.save_position("pos1")
self.reader.save_position("pos2")
positions = self.reader.list_saved_positions()
self.assertIn("pos1", positions)
self.assertIn("pos2", positions)
def test_delete_position(self):
"""Test deleting a saved position"""
self.reader.save_position("temp_pos")
success = self.reader.delete_position("temp_pos")
self.assertTrue(success)
positions = self.reader.list_saved_positions()
self.assertNotIn("temp_pos", positions)
def test_delete_nonexistent_position(self):
"""Test deleting a non-existent position"""
success = self.reader.delete_position("nonexistent")
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()
+316
View File
@@ -0,0 +1,316 @@
"""
Integration tests for EbookReader highlighting functionality.
Tests the highlighting API in EbookReader including:
- highlight_word()
- highlight_selection()
- remove_highlight()
- list_highlights()
- get_highlights_for_current_page()
- clear_highlights()
- Highlight rendering on pages
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from PIL import Image
from dreader.application import EbookReader
from pyWebLayout.core.highlight import HighlightColor
class TestEbookReaderHighlighting(unittest.TestCase):
"""Test EbookReader highlighting API"""
def setUp(self):
"""Create temporary directories and reader"""
self.temp_dir = tempfile.mkdtemp()
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
self.highlights_dir = Path(self.temp_dir) / "highlights"
self.reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
def tearDown(self):
"""Clean up"""
self.reader.close()
shutil.rmtree(self.temp_dir)
def test_highlight_manager_not_initialized_before_load(self):
"""Test that highlight manager is None before loading a book"""
self.assertIsNone(self.reader.highlight_manager)
def test_highlight_manager_initialized_after_load(self):
"""Test that highlight manager is initialized after loading"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
self.assertIsNotNone(self.reader.highlight_manager)
self.assertEqual(self.reader.highlight_manager.document_id, "test")
def test_highlight_word_without_book(self):
"""Test highlighting returns None when no book loaded"""
highlight_id = self.reader.highlight_word(100, 100)
self.assertIsNone(highlight_id)
def test_highlight_word(self):
"""Test highlighting a word at a pixel location"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get a page to ensure content is rendered
page_img = self.reader.get_current_page()
self.assertIsNotNone(page_img)
# Try to highlight a word (coordinates may not hit text in test)
highlight_id = self.reader.highlight_word(
200, 300,
color=HighlightColor.YELLOW.value,
note="Test highlight"
)
# If we hit text, should return an ID
if highlight_id:
self.assertIsInstance(highlight_id, str)
self.assertGreater(len(highlight_id), 0)
# Verify it was added
highlights = self.reader.list_highlights()
self.assertGreater(len(highlights), 0)
def test_highlight_selection(self):
"""Test highlighting a range of words"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get a page
page_img = self.reader.get_current_page()
self.assertIsNotNone(page_img)
# Try to highlight a selection
highlight_id = self.reader.highlight_selection(
start=(100, 200),
end=(400, 250),
color=HighlightColor.BLUE.value,
tags=["selection", "test"]
)
# If selection hit text, should return an ID
if highlight_id:
self.assertIsInstance(highlight_id, str)
highlights = self.reader.list_highlights()
found = False
for h in highlights:
if h.id == highlight_id:
found = True
self.assertEqual(h.color, HighlightColor.BLUE.value)
self.assertIn("selection", h.tags)
break
self.assertTrue(found)
def test_list_highlights_empty(self):
"""Test listing highlights when none exist"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
highlights = self.reader.list_highlights()
self.assertEqual(len(highlights), 0)
def test_remove_highlight(self):
"""Test removing a highlight"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
page_img = self.reader.get_current_page()
# Create a highlight directly via manager for testing
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="test-highlight",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Test"
)
self.reader.highlight_manager.add_highlight(highlight)
# Verify it exists
self.assertEqual(len(self.reader.list_highlights()), 1)
# Remove it
result = self.reader.remove_highlight("test-highlight")
self.assertTrue(result)
# Verify it's gone
self.assertEqual(len(self.reader.list_highlights()), 0)
def test_remove_nonexistent_highlight(self):
"""Test removing a highlight that doesn't exist"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
result = self.reader.remove_highlight("nonexistent")
self.assertFalse(result)
def test_clear_highlights(self):
"""Test clearing all highlights"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Add some highlights directly
from pyWebLayout.core.highlight import Highlight
for i in range(3):
highlight = Highlight(
id=f"test-{i}",
bounds=[(100 + i * 50, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text=f"Word{i}"
)
self.reader.highlight_manager.add_highlight(highlight)
self.assertEqual(len(self.reader.list_highlights()), 3)
self.reader.clear_highlights()
self.assertEqual(len(self.reader.list_highlights()), 0)
def test_get_highlights_for_current_page(self):
"""Test getting highlights for current page"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Add highlights at different locations
from pyWebLayout.core.highlight import Highlight
# On page
highlight1 = Highlight(
id="on-page",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="OnPage"
)
# Off page (beyond page height)
highlight2 = Highlight(
id="off-page",
bounds=[(100, 5000, 50, 15)],
color=HighlightColor.YELLOW.value,
text="OffPage"
)
self.reader.highlight_manager.add_highlight(highlight1)
self.reader.highlight_manager.add_highlight(highlight2)
# Get highlights for current page
page_highlights = self.reader.get_highlights_for_current_page()
# Should only get the on-page highlight
self.assertEqual(len(page_highlights), 1)
self.assertEqual(page_highlights[0].id, "on-page")
def test_get_current_page_with_highlights(self):
"""Test that get_current_page renders highlights"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Get page without highlights
page_no_highlights = self.reader.get_current_page(include_highlights=False)
self.assertIsNotNone(page_no_highlights)
# Add a highlight
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="test",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Test"
)
self.reader.highlight_manager.add_highlight(highlight)
# Get page with highlights
page_with_highlights = self.reader.get_current_page(include_highlights=True)
self.assertIsNotNone(page_with_highlights)
# Both should be valid images
self.assertIsInstance(page_no_highlights, Image.Image)
self.assertIsInstance(page_with_highlights, Image.Image)
# Should have same size
self.assertEqual(page_no_highlights.size, page_with_highlights.size)
def test_highlight_persistence(self):
"""Test that highlights persist across reader instances"""
# Load book and add highlight
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
from pyWebLayout.core.highlight import Highlight
highlight = Highlight(
id="persistent",
bounds=[(100, 200, 50, 15)],
color=HighlightColor.YELLOW.value,
text="Persisted"
)
self.reader.highlight_manager.add_highlight(highlight)
self.assertEqual(len(self.reader.list_highlights()), 1)
# Close reader
self.reader.close()
# Create new reader for same book
new_reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
success = new_reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Should load existing highlights
highlights = new_reader.list_highlights()
self.assertEqual(len(highlights), 1)
self.assertEqual(highlights[0].id, "persistent")
self.assertEqual(highlights[0].text, "Persisted")
new_reader.close()
def test_render_highlights_helper(self):
"""Test the _render_highlights helper method"""
success = self.reader.load_epub("tests/data/test.epub")
self.assertTrue(success)
# Create a simple test image
from PIL import Image as PILImage
test_img = PILImage.new('RGB', (100, 100), color='white')
from pyWebLayout.core.highlight import Highlight
highlights = [
Highlight(
id="h1",
bounds=[(10, 10, 30, 20)],
color=(255, 255, 0, 100),
text="Test"
)
]
# Render highlights
result_img = self.reader._render_highlights(test_img, highlights)
self.assertIsNotNone(result_img)
self.assertIsInstance(result_img, PILImage.Image)
self.assertEqual(result_img.size, test_img.size)
# Result should preserve the input mode
self.assertEqual(result_img.mode, 'RGB')
if __name__ == '__main__':
unittest.main()
+455
View File
@@ -0,0 +1,455 @@
"""
Comprehensive tests for the ereader layout system.
Tests the complete ereader functionality including position tracking,
font scaling, chapter navigation, and page buffering.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.ereader_layout import RenderingPosition, ChapterNavigator, FontScaler, BidirectionalLayouter
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager, BookmarkManager, create_ereader_manager
class TestRenderingPosition(unittest.TestCase):
"""Test the RenderingPosition class"""
def test_position_creation(self):
"""Test creating a rendering position"""
pos = RenderingPosition(
chapter_index=1,
block_index=5,
word_index=10,
table_row=2,
table_col=3
)
self.assertEqual(pos.chapter_index, 1)
self.assertEqual(pos.block_index, 5)
self.assertEqual(pos.word_index, 10)
self.assertEqual(pos.table_row, 2)
self.assertEqual(pos.table_col, 3)
def test_position_serialization(self):
"""Test position serialization and deserialization"""
pos = RenderingPosition(
chapter_index=1,
block_index=5,
word_index=10,
remaining_pretext="test"
)
# Serialize to dict
pos_dict = pos.to_dict()
self.assertIsInstance(pos_dict, dict)
self.assertEqual(pos_dict['chapter_index'], 1)
self.assertEqual(pos_dict['remaining_pretext'], "test")
# Deserialize from dict
pos2 = RenderingPosition.from_dict(pos_dict)
self.assertEqual(pos, pos2)
def test_position_copy(self):
"""Test position copying"""
pos = RenderingPosition(chapter_index=1, block_index=5)
pos_copy = pos.copy()
self.assertEqual(pos, pos_copy)
self.assertIsNot(pos, pos_copy) # Different objects
# Modify copy
pos_copy.word_index = 10
self.assertNotEqual(pos, pos_copy)
def test_position_equality_and_hashing(self):
"""Test position equality and hashing"""
pos1 = RenderingPosition(chapter_index=1, block_index=5)
pos2 = RenderingPosition(chapter_index=1, block_index=5)
pos3 = RenderingPosition(chapter_index=1, block_index=6)
self.assertEqual(pos1, pos2)
self.assertNotEqual(pos1, pos3)
# Test hashing (for use as dict keys)
pos_dict = {pos1: "test"}
self.assertEqual(pos_dict[pos2], "test") # Should work due to equality
class TestChapterNavigator(unittest.TestCase):
"""Test the ChapterNavigator class"""
def setUp(self):
"""Set up test data"""
self.font = Font()
# Create test blocks with headings
self.blocks = [
Paragraph(self.font), # Block 0
Heading(HeadingLevel.H1, self.font), # Block 1 - Chapter 1
Paragraph(self.font), # Block 2
Heading(HeadingLevel.H2, self.font), # Block 3 - Subsection
Paragraph(self.font), # Block 4
Heading(HeadingLevel.H1, self.font), # Block 5 - Chapter 2
Paragraph(self.font), # Block 6
]
# Add text to headings
self.blocks[1].add_word(Word("Chapter", self.font))
self.blocks[1].add_word(Word("One", self.font))
self.blocks[3].add_word(Word("Subsection", self.font))
self.blocks[3].add_word(Word("A", self.font))
self.blocks[5].add_word(Word("Chapter", self.font))
self.blocks[5].add_word(Word("Two", self.font))
def test_chapter_detection(self):
"""Test that chapters are detected correctly"""
navigator = ChapterNavigator(self.blocks)
self.assertEqual(len(navigator.chapters), 3) # 2 H1s + 1 H2
# Check chapter titles
titles = [chapter.title for chapter in navigator.chapters]
self.assertIn("Chapter One", titles)
self.assertIn("Subsection A", titles)
self.assertIn("Chapter Two", titles)
def test_table_of_contents(self):
"""Test table of contents generation"""
navigator = ChapterNavigator(self.blocks)
toc = navigator.get_table_of_contents()
self.assertEqual(len(toc), 3)
# Check first entry
title, level, position = toc[0]
self.assertEqual(title, "Chapter One")
self.assertEqual(level, HeadingLevel.H1)
self.assertIsInstance(position, RenderingPosition)
def test_chapter_position_lookup(self):
"""Test looking up chapter positions"""
navigator = ChapterNavigator(self.blocks)
pos = navigator.get_chapter_position("Chapter One")
self.assertIsNotNone(pos)
self.assertEqual(pos.chapter_index, 0)
pos = navigator.get_chapter_position("Nonexistent Chapter")
self.assertIsNone(pos)
def test_current_chapter_detection(self):
"""Test detecting current chapter from position"""
navigator = ChapterNavigator(self.blocks)
# Position in first chapter
pos = RenderingPosition(chapter_index=0, block_index=2)
chapter = navigator.get_current_chapter(pos)
self.assertIsNotNone(chapter)
self.assertEqual(chapter.title, "Chapter One")
class TestFontScaler(unittest.TestCase):
"""Test the FontScaler class"""
def test_font_scaling(self):
"""Test font scaling functionality"""
original_font = Font(font_size=12)
# Test no scaling
scaled_font = FontScaler.scale_font(original_font, 1.0)
self.assertEqual(scaled_font.font_size, 12)
# Test 2x scaling
scaled_font = FontScaler.scale_font(original_font, 2.0)
self.assertEqual(scaled_font.font_size, 24)
# Test 0.5x scaling
scaled_font = FontScaler.scale_font(original_font, 0.5)
self.assertEqual(scaled_font.font_size, 6)
# Test minimum size constraint
scaled_font = FontScaler.scale_font(original_font, 0.01)
self.assertGreaterEqual(scaled_font.font_size, 1)
def test_word_spacing_scaling(self):
"""Test word spacing scaling"""
original_spacing = (5, 15)
# Test no scaling
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 1.0)
self.assertEqual(scaled_spacing, (5, 15))
# Test 2x scaling
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 2.0)
self.assertEqual(scaled_spacing, (10, 30))
# Test minimum constraints
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 0.1)
self.assertGreaterEqual(scaled_spacing[0], 1)
self.assertGreaterEqual(scaled_spacing[1], 2)
class TestBookmarkManager(unittest.TestCase):
"""Test the BookmarkManager class"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.document_id = "test_document"
self.bookmark_manager = BookmarkManager(self.document_id, self.temp_dir)
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir)
def test_bookmark_operations(self):
"""Test bookmark add/remove/get operations"""
pos = RenderingPosition(chapter_index=1, block_index=5)
# Add bookmark
self.bookmark_manager.add_bookmark("test_bookmark", pos)
# Get bookmark
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
self.assertEqual(retrieved_pos, pos)
# List bookmarks
bookmarks = self.bookmark_manager.list_bookmarks()
self.assertEqual(len(bookmarks), 1)
self.assertEqual(bookmarks[0][0], "test_bookmark")
self.assertEqual(bookmarks[0][1], pos)
# Remove bookmark
success = self.bookmark_manager.remove_bookmark("test_bookmark")
self.assertTrue(success)
# Verify removal
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
self.assertIsNone(retrieved_pos)
def test_reading_position_persistence(self):
"""Test saving and loading reading position"""
pos = RenderingPosition(chapter_index=2, block_index=10, word_index=5)
# Save position
self.bookmark_manager.save_reading_position(pos)
# Create new manager instance (simulates app restart)
new_manager = BookmarkManager(self.document_id, self.temp_dir)
# Load position
loaded_pos = new_manager.load_reading_position()
self.assertEqual(loaded_pos, pos)
def test_bookmark_persistence(self):
"""Test that bookmarks persist across manager instances"""
pos = RenderingPosition(chapter_index=1, block_index=5)
# Add bookmark
self.bookmark_manager.add_bookmark("persistent_bookmark", pos)
# Create new manager instance
new_manager = BookmarkManager(self.document_id, self.temp_dir)
# Verify bookmark exists
retrieved_pos = new_manager.get_bookmark("persistent_bookmark")
self.assertEqual(retrieved_pos, pos)
class TestEreaderLayoutManager(unittest.TestCase):
"""Test the complete EreaderLayoutManager"""
def setUp(self):
"""Set up test data"""
self.temp_dir = tempfile.mkdtemp()
self.font = Font()
# Create test document with multiple paragraphs and headings
self.blocks = []
# Add a heading
heading = Heading(HeadingLevel.H1, self.font)
heading.add_word(Word("Test", self.font))
heading.add_word(Word("Chapter", self.font))
self.blocks.append(heading)
# Add several paragraphs with multiple words
for i in range(3):
paragraph = Paragraph(self.font)
for j in range(20): # 20 words per paragraph
paragraph.add_word(Word(f"Word{i}_{j}", self.font))
self.blocks.append(paragraph)
self.page_size = (400, 600)
self.document_id = "test_document"
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir)
def test_manager_initialization(self):
"""Test ereader manager initialization"""
# Change to temp directory for bookmarks
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
self.assertEqual(manager.page_size, self.page_size)
self.assertEqual(manager.document_id, self.document_id)
self.assertEqual(manager.font_scale, 1.0)
self.assertIsInstance(manager.current_position, RenderingPosition)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_font_scaling(self):
"""Test font scaling functionality"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Test initial scale
self.assertEqual(manager.get_font_scale(), 1.0)
# Test scaling
page = manager.set_font_scale(1.5)
self.assertEqual(manager.get_font_scale(), 1.5)
self.assertIsNotNone(page)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_table_of_contents(self):
"""Test table of contents functionality"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
toc = manager.get_table_of_contents()
self.assertGreater(len(toc), 0)
# Check first entry
title, level, position = toc[0]
self.assertEqual(title, "Test Chapter")
self.assertEqual(level, HeadingLevel.H1)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_bookmark_functionality(self):
"""Test bookmark functionality"""
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Add bookmark
success = manager.add_bookmark("test_bookmark")
self.assertTrue(success)
# List bookmarks
bookmarks = manager.list_bookmarks()
self.assertEqual(len(bookmarks), 1)
self.assertEqual(bookmarks[0][0], "test_bookmark")
# Jump to bookmark (should work even though it's the same position)
page = manager.jump_to_bookmark("test_bookmark")
self.assertIsNotNone(page)
# Remove bookmark
success = manager.remove_bookmark("test_bookmark")
self.assertTrue(success)
manager.shutdown()
def test_progress_tracking(self):
"""Test reading progress tracking"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = EreaderLayoutManager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
# Initial progress should be 0
progress = manager.get_reading_progress()
self.assertGreaterEqual(progress, 0.0)
self.assertLessEqual(progress, 1.0)
# Get position info
info = manager.get_position_info()
self.assertIn('position', info)
self.assertIn('progress', info)
self.assertIn('font_scale', info)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_convenience_function(self):
"""Test the convenience function"""
original_cwd = Path.cwd()
try:
import os
os.chdir(self.temp_dir)
manager = create_ereader_manager(
self.blocks,
self.page_size,
self.document_id,
bookmarks_dir=self.temp_dir
)
self.assertIsInstance(manager, EreaderLayoutManager)
self.assertEqual(manager.page_size, self.page_size)
manager.shutdown()
finally:
os.chdir(original_cwd)
if __name__ == '__main__':
unittest.main()
+228
View File
@@ -0,0 +1,228 @@
"""
Unit tests for example scripts.
This test suite validates that all example scripts:
1. Can be imported without errors (syntax checks, import validation)
2. Have valid import statements
3. Can run their main functions without crashing (when applicable)
This helps catch issues like:
- Incorrect import paths
- Missing dependencies
- API breakages that affect examples
"""
import unittest
import importlib.util
import sys
import os
from pathlib import Path
class TestExampleImports(unittest.TestCase):
"""Test that all example scripts can be imported successfully"""
def setUp(self):
"""Set up test fixtures"""
# Get the project root directory
self.project_root = Path(__file__).parent.parent
self.examples_dir = self.project_root / "examples"
# Add project root to Python path if not already there
if str(self.project_root) not in sys.path:
sys.path.insert(0, str(self.project_root))
def _import_module_from_file(self, file_path: Path):
"""
Import a Python module from a file path.
Args:
file_path: Path to the Python file
Returns:
The imported module
Raises:
Any import errors that occur
"""
spec = importlib.util.spec_from_file_location(file_path.stem, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load spec for {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[file_path.stem] = module
spec.loader.exec_module(module)
return module
def test_word_selection_highlighting_imports(self):
"""Test word_selection_highlighting.py can be imported"""
example_file = self.examples_dir / "word_selection_highlighting.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
# Verify key components are available
self.assertTrue(hasattr(module, 'draw_highlight'))
self.assertTrue(hasattr(module, 'example_1_single_word_selection'))
self.assertTrue(hasattr(module, 'example_2_range_selection'))
self.assertTrue(hasattr(module, 'example_3_interactive_word_lookup'))
self.assertTrue(hasattr(module, 'example_4_multi_word_annotation'))
self.assertTrue(hasattr(module, 'example_5_link_highlighting'))
except ImportError as e:
self.fail(f"Failed to import word_selection_highlighting.py: {e}")
def test_demo_pagination_imports(self):
"""Test demo_pagination.py can be imported"""
example_file = self.examples_dir / "demo_pagination.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_pagination.py: {e}")
def test_demo_toc_overlay_imports(self):
"""Test demo_toc_overlay.py can be imported"""
example_file = self.examples_dir / "demo_toc_overlay.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_toc_overlay.py: {e}")
def test_demo_settings_overlay_imports(self):
"""Test demo_settings_overlay.py can be imported"""
example_file = self.examples_dir / "demo_settings_overlay.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
except ImportError as e:
self.fail(f"Failed to import demo_settings_overlay.py: {e}")
def test_library_reading_integration_imports(self):
"""Test library_reading_integration.py can be imported"""
example_file = self.examples_dir / "library_reading_integration.py"
self.assertTrue(example_file.exists(), f"Example file not found: {example_file}")
try:
module = self._import_module_from_file(example_file)
self.assertTrue(hasattr(module, 'main'))
self.assertTrue(hasattr(module, 'simulate_mode_transition_workflow'))
except ImportError as e:
self.fail(f"Failed to import library_reading_integration.py: {e}")
def test_all_examples_have_correct_dreader_imports(self):
"""
Verify all example scripts use correct import paths for dreader classes.
This test specifically checks that examples don't use outdated import paths
like 'from dreader.application import' when they should use 'from dreader import'.
"""
# Get all Python files in examples directory
example_files = list(self.examples_dir.glob("*.py"))
problematic_imports = []
for example_file in example_files:
# Skip __init__.py and other special files
if example_file.name.startswith('_'):
continue
with open(example_file, 'r') as f:
content = f.read()
# Check for problematic import patterns
if 'from pyWebLayout.io.gesture import' in content:
problematic_imports.append(
f"{example_file.name}: Uses 'from pyWebLayout.io.gesture import' "
f"(should be 'from dreader import')"
)
if 'from dreader.application import EbookReader' in content:
# This is acceptable, but check if TouchEvent/GestureType are also imported correctly
if 'from pyWebLayout.io.gesture import TouchEvent' in content:
problematic_imports.append(
f"{example_file.name}: Mixes dreader.application and pyWebLayout.io.gesture imports"
)
if problematic_imports:
self.fail(
"Found problematic imports in example files:\n" +
"\n".join(f" - {issue}" for issue in problematic_imports)
)
class TestExampleFunctions(unittest.TestCase):
"""Test key functionality in example scripts"""
def test_draw_highlight_function(self):
"""Test the draw_highlight function from word_selection_highlighting"""
from PIL import Image
# Import the module
project_root = Path(__file__).parent.parent
example_file = project_root / "examples" / "word_selection_highlighting.py"
spec = importlib.util.spec_from_file_location("word_selection_highlighting", example_file)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Create a test image
test_image = Image.new('RGBA', (100, 100), (255, 255, 255, 255))
# Test the draw_highlight function
bounds = (10, 10, 50, 20)
result = module.draw_highlight(test_image, bounds)
# Verify the result is an image
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, (100, 100))
self.assertEqual(result.mode, 'RGBA')
class TestExampleDocumentation(unittest.TestCase):
"""Test that examples have proper documentation"""
def test_all_examples_have_docstrings(self):
"""Verify all example scripts have module docstrings"""
project_root = Path(__file__).parent.parent
examples_dir = project_root / "examples"
example_files = [
f for f in examples_dir.glob("*.py")
if not f.name.startswith('_') and f.name not in ['__init__.py']
]
missing_docstrings = []
for example_file in example_files:
spec = importlib.util.spec_from_file_location(example_file.stem, example_file)
if spec is None or spec.loader is None:
continue
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
if not module.__doc__ or len(module.__doc__.strip()) < 10:
missing_docstrings.append(example_file.name)
except:
# If module can't be loaded, skip docstring check
# (import test will catch the error)
pass
if missing_docstrings:
self.fail(
f"Examples missing proper docstrings: {', '.join(missing_docstrings)}"
)
if __name__ == '__main__':
unittest.main()
+287
View File
@@ -0,0 +1,287 @@
"""
Unit tests for gesture event system.
Tests TouchEvent, GestureType, GestureResponse, and HAL integration.
"""
import unittest
from dreader.gesture import (
GestureType,
TouchEvent,
GestureResponse,
ActionType
)
class TestGestureType(unittest.TestCase):
"""Test GestureType enum"""
def test_gesture_types_exist(self):
"""Test all gesture types are defined"""
self.assertEqual(GestureType.TAP.value, "tap")
self.assertEqual(GestureType.LONG_PRESS.value, "long_press")
self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left")
self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right")
self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up")
self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down")
self.assertEqual(GestureType.PINCH_IN.value, "pinch_in")
self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out")
self.assertEqual(GestureType.DRAG_START.value, "drag_start")
self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move")
self.assertEqual(GestureType.DRAG_END.value, "drag_end")
class TestTouchEvent(unittest.TestCase):
"""Test TouchEvent dataclass"""
def test_init_basic(self):
"""Test basic TouchEvent creation"""
event = TouchEvent(
gesture=GestureType.TAP,
x=450,
y=320
)
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
self.assertIsNone(event.x2)
self.assertIsNone(event.y2)
self.assertEqual(event.timestamp_ms, 0)
def test_init_with_secondary_point(self):
"""Test TouchEvent with secondary point (pinch/drag)"""
event = TouchEvent(
gesture=GestureType.PINCH_OUT,
x=400,
y=300,
x2=450,
y2=350,
timestamp_ms=12345.678
)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.y, 300)
self.assertEqual(event.x2, 450)
self.assertEqual(event.y2, 350)
self.assertEqual(event.timestamp_ms, 12345.678)
def test_from_hal_basic(self):
"""Test parsing TouchEvent from HAL format"""
hal_data = {
'gesture': 'tap',
'x': 450,
'y': 320
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
def test_from_hal_complete(self):
"""Test parsing TouchEvent with all fields from HAL"""
hal_data = {
'gesture': 'pinch_out',
'x': 400,
'y': 300,
'x2': 450,
'y2': 350,
'timestamp': 12345.678
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.y, 300)
self.assertEqual(event.x2, 450)
self.assertEqual(event.y2, 350)
self.assertEqual(event.timestamp_ms, 12345.678)
def test_to_dict(self):
"""Test TouchEvent serialization"""
event = TouchEvent(
gesture=GestureType.SWIPE_LEFT,
x=600,
y=400,
timestamp_ms=12345.0
)
d = event.to_dict()
self.assertEqual(d['gesture'], 'swipe_left')
self.assertEqual(d['x'], 600)
self.assertEqual(d['y'], 400)
self.assertIsNone(d['x2'])
self.assertIsNone(d['y2'])
self.assertEqual(d['timestamp_ms'], 12345.0)
class TestGestureResponse(unittest.TestCase):
"""Test GestureResponse dataclass"""
def test_init(self):
"""Test GestureResponse creation"""
response = GestureResponse(
action="page_turn",
data={"direction": "forward", "progress": 0.42}
)
self.assertEqual(response.action, "page_turn")
self.assertEqual(response.data['direction'], "forward")
self.assertEqual(response.data['progress'], 0.42)
def test_to_dict(self):
"""Test GestureResponse serialization"""
response = GestureResponse(
action="define",
data={"word": "ephemeral", "bounds": (100, 200, 50, 20)}
)
d = response.to_dict()
self.assertEqual(d['action'], "define")
self.assertEqual(d['data']['word'], "ephemeral")
self.assertEqual(d['data']['bounds'], (100, 200, 50, 20))
def test_to_dict_empty_data(self):
"""Test GestureResponse with empty data"""
response = GestureResponse(action="none", data={})
d = response.to_dict()
self.assertEqual(d['action'], "none")
self.assertEqual(d['data'], {})
class TestActionType(unittest.TestCase):
"""Test ActionType constants"""
def test_action_types_defined(self):
"""Test all action type constants are defined"""
self.assertEqual(ActionType.NONE, "none")
self.assertEqual(ActionType.PAGE_TURN, "page_turn")
self.assertEqual(ActionType.NAVIGATE, "navigate")
self.assertEqual(ActionType.DEFINE, "define")
self.assertEqual(ActionType.SELECT, "select")
self.assertEqual(ActionType.ZOOM, "zoom")
self.assertEqual(ActionType.BOOK_LOADED, "book_loaded")
self.assertEqual(ActionType.WORD_SELECTED, "word_selected")
self.assertEqual(ActionType.SHOW_MENU, "show_menu")
self.assertEqual(ActionType.SELECTION_START, "selection_start")
self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update")
self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete")
self.assertEqual(ActionType.AT_START, "at_start")
self.assertEqual(ActionType.AT_END, "at_end")
self.assertEqual(ActionType.ERROR, "error")
class TestHALIntegration(unittest.TestCase):
"""Test HAL integration scenarios"""
def test_hal_tap_flow(self):
"""Test complete HAL tap event flow"""
# Simulate HAL sending tap event
hal_data = {
'gesture': 'tap',
'x': 450,
'y': 320,
'timestamp': 1234567890.123
}
# Parse event
event = TouchEvent.from_hal(hal_data)
# Verify event
self.assertEqual(event.gesture, GestureType.TAP)
self.assertEqual(event.x, 450)
self.assertEqual(event.y, 320)
# Simulate business logic response
response = GestureResponse(
action=ActionType.WORD_SELECTED,
data={"word": "hello", "bounds": (440, 310, 50, 20)}
)
# Serialize for Flask
response_dict = response.to_dict()
self.assertEqual(response_dict['action'], "word_selected")
self.assertEqual(response_dict['data']['word'], "hello")
def test_hal_pinch_flow(self):
"""Test complete HAL pinch event flow"""
# Simulate HAL sending pinch event with two touch points
hal_data = {
'gesture': 'pinch_out',
'x': 400,
'y': 500,
'x2': 500,
'y2': 500,
'timestamp': 1234567891.456
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
self.assertEqual(event.x, 400)
self.assertEqual(event.x2, 500)
def test_hal_swipe_flow(self):
"""Test complete HAL swipe event flow"""
hal_data = {
'gesture': 'swipe_left',
'x': 600,
'y': 400
}
event = TouchEvent.from_hal(hal_data)
self.assertEqual(event.gesture, GestureType.SWIPE_LEFT)
# Expected response
response = GestureResponse(
action=ActionType.PAGE_TURN,
data={"direction": "forward", "progress": 0.25}
)
self.assertEqual(response.action, "page_turn")
def test_hal_drag_selection_flow(self):
"""Test complete drag selection flow"""
# Drag start
start_data = {
'gesture': 'drag_start',
'x': 100,
'y': 200
}
start_event = TouchEvent.from_hal(start_data)
self.assertEqual(start_event.gesture, GestureType.DRAG_START)
# Drag move
move_data = {
'gesture': 'drag_move',
'x': 300,
'y': 250
}
move_event = TouchEvent.from_hal(move_data)
self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE)
# Drag end
end_data = {
'gesture': 'drag_end',
'x': 500,
'y': 300
}
end_event = TouchEvent.from_hal(end_data)
self.assertEqual(end_event.gesture, GestureType.DRAG_END)
if __name__ == '__main__':
unittest.main()
+250
View File
@@ -0,0 +1,250 @@
"""
Unit tests for library interaction and tap detection.
These tests demonstrate the issue with interactive images in the library
and verify that tap detection works correctly.
"""
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from dreader import LibraryManager
class TestLibraryInteraction(unittest.TestCase):
"""Test library browsing and tap interaction"""
def setUp(self):
"""Set up test library"""
self.library_path = Path(__file__).parent / 'data' / 'library-epub'
self.library = LibraryManager(
library_path=str(self.library_path),
page_size=(800, 1200)
)
def tearDown(self):
"""Clean up"""
self.library.cleanup()
def test_library_scan(self):
"""Test that library scanning finds books"""
books = self.library.scan_library()
# Should find at least one book
self.assertGreater(len(books), 0, "Library should contain at least one book")
# Each book should have required fields
for book in books:
self.assertIn('path', book)
self.assertIn('title', book)
self.assertIn('filename', book)
def test_library_table_creation(self):
"""Test that library table can be created with pagination"""
books = self.library.scan_library()
table = self.library.create_library_table()
# Table should exist
self.assertIsNotNone(table)
# Table should have body rows for 2-column grid layout
# With pagination, we only show books_per_page books, not all books
# Calculate expected rows based on current page's books
books_on_page = min(self.library.books_per_page, len(books) - (self.library.current_page * self.library.books_per_page))
# Each pair of books gets 2 rows (cover row + detail row)
expected_rows = ((books_on_page + 1) // 2) * 2
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), expected_rows)
def test_library_rendering(self):
"""Test that library can be rendered to image"""
self.library.scan_library()
self.library.create_library_table()
# Render library
image = self.library.render_library()
# Image should be created with correct size
self.assertIsNotNone(image)
self.assertEqual(image.size, self.library.page_size)
def test_tap_detection_first_book(self):
"""Test that tapping on first book row selects it
The entire row is interactive, so tapping anywhere in the row
(not just on the cover image) will select the book.
"""
books = self.library.scan_library()
self.library.create_library_table()
self.library.render_library()
# Tap anywhere in the first book's row
# Based on layout: padding 30px, caption ~40px, first row starts at ~70px
selected_path = self.library.handle_library_tap(x=100, y=100)
# Should select the first book
self.assertIsNotNone(selected_path, "Tap should select a book")
self.assertEqual(selected_path, books[0]['path'], "Should select first book")
def test_tap_detection_second_book(self):
"""Test that tapping on second book selects it"""
books = self.library.scan_library()
if len(books) < 2:
self.skipTest("Need at least 2 books for this test")
self.library.create_library_table()
self.library.render_library()
# Tap in the region of the second book
# Row height is ~180px, so second book is at ~70 + 180 = 250px
selected_path = self.library.handle_library_tap(x=400, y=250)
# Should select the second book
self.assertIsNotNone(selected_path, "Tap should select a book")
self.assertEqual(selected_path, books[1]['path'], "Should select second book")
def test_tap_outside_table(self):
"""Test that tapping outside table returns None"""
self.library.scan_library()
self.library.create_library_table()
self.library.render_library()
# Tap outside the table area (far right)
selected_path = self.library.handle_library_tap(x=1000, y=100)
# Should not select anything
self.assertIsNone(selected_path, "Tap outside table should not select anything")
def test_tap_above_table(self):
"""Test that tapping in caption area returns None"""
self.library.scan_library()
self.library.create_library_table()
self.library.render_library()
# Tap in caption area (above first row)
selected_path = self.library.handle_library_tap(x=400, y=40)
# Should not select anything
self.assertIsNone(selected_path, "Tap in caption should not select anything")
def test_tap_below_last_book(self):
"""Test that tapping below all books returns None"""
books = self.library.scan_library()
self.library.create_library_table()
self.library.render_library()
# Tap way below the last book
# With 5 books and row height 180px: ~70 + (5 * 180) = 970px
selected_path = self.library.handle_library_tap(x=400, y=1100)
# Should not select anything
self.assertIsNone(selected_path, "Tap below last book should not select anything")
def test_multiple_taps(self):
"""Test that multiple taps work correctly with 2-column grid layout"""
books = self.library.scan_library()
if len(books) < 3:
self.skipTest("Need at least 3 books for this test")
self.library.create_library_table()
self.library.render_library()
# In 2-column layout:
# Books 0 and 1 are in the first pair (rows 0-1: cover and detail)
# Books 2 and 3 are in the second pair (rows 2-3: cover and detail)
# Tap first book (left column, first pair cover row)
path1 = self.library.handle_library_tap(x=100, y=100)
self.assertEqual(path1, books[0]['path'])
# Tap second book (right column, first pair cover row)
path2 = self.library.handle_library_tap(x=500, y=100)
self.assertEqual(path2, books[1]['path'])
# Tap third book (left column, second pair cover row)
path3 = self.library.handle_library_tap(x=100, y=360)
self.assertEqual(path3, books[2]['path'])
# All should be different
self.assertNotEqual(path1, path2)
self.assertNotEqual(path2, path3)
self.assertNotEqual(path1, path3)
def test_pagination(self):
"""Test library pagination with fake book data"""
# Create fake books (20 books to ensure multiple pages)
fake_books = []
for i in range(20):
fake_books.append({
'path': f'/fake/path/book_{i}.epub',
'title': f'Book Title {i}',
'author': f'Author {i}',
'filename': f'book_{i}.epub',
'cover_data': None,
'cover_path': None
})
# Create library with 6 books per page
library = LibraryManager(
library_path=str(self.library_path),
page_size=(800, 1200),
books_per_page=6
)
library.books = fake_books
# Test initial state
self.assertEqual(library.current_page, 0)
self.assertEqual(library.get_total_pages(), 4) # 20 books / 6 per page = 4 pages
# Test creating table for first page
table = library.create_library_table()
self.assertIsNotNone(table)
# 6 books = 3 pairs = 6 rows (3 cover rows + 3 detail rows)
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6)
# Test navigation to next page
self.assertTrue(library.next_page())
self.assertEqual(library.current_page, 1)
# Create table for second page
table = library.create_library_table()
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6) # Still 6 books on page 2
# Test navigation to last page
library.set_page(3)
self.assertEqual(library.current_page, 3)
table = library.create_library_table()
body_rows = list(table.body_rows())
# Page 4 has 2 books (20 - 18 = 2) = 1 pair = 2 rows
self.assertEqual(len(body_rows), 2)
# Test can't go beyond last page
self.assertFalse(library.next_page())
self.assertEqual(library.current_page, 3)
# Test navigation to previous page
self.assertTrue(library.previous_page())
self.assertEqual(library.current_page, 2)
# Test navigation to first page
library.set_page(0)
self.assertEqual(library.current_page, 0)
# Test can't go before first page
self.assertFalse(library.previous_page())
self.assertEqual(library.current_page, 0)
# Test invalid page number
self.assertFalse(library.set_page(-1))
self.assertFalse(library.set_page(100))
self.assertEqual(library.current_page, 0) # Should stay on current page
library.cleanup()
if __name__ == '__main__':
unittest.main()
+196
View File
@@ -0,0 +1,196 @@
"""
Tests for the unified navigation overlay (TOC + Bookmarks tabs)
"""
import pytest
from pathlib import Path
from PIL import Image
from dreader.application import EbookReader
from dreader.state import OverlayState
from dreader.gesture import TouchEvent, GestureType, ActionType
@pytest.fixture
def reader_with_book():
"""Create a reader with a test book loaded"""
reader = EbookReader(page_size=(400, 600), margin=10)
# Load a simple test book
test_book = Path(__file__).parent.parent / "examples" / "books" / "hamlet.epub"
if test_book.exists():
reader.load_epub(str(test_book))
else:
# Fallback: create simple HTML for testing
html = """
<html>
<body>
<h1>Chapter 1</h1>
<p>This is chapter 1 content</p>
<h1>Chapter 2</h1>
<p>This is chapter 2 content</p>
</body>
</html>
"""
reader.load_html(html, title="Test Book", author="Test Author", document_id="test")
yield reader
reader.close()
def test_open_navigation_overlay_contents_tab(reader_with_book):
"""Test opening navigation overlay with Contents tab active"""
reader = reader_with_book
# Open navigation overlay with contents tab
image = reader.open_navigation_overlay(active_tab="contents")
assert image is not None
assert isinstance(image, Image.Image)
assert reader.get_overlay_state() == OverlayState.NAVIGATION
assert reader.is_overlay_open()
def test_open_navigation_overlay_bookmarks_tab(reader_with_book):
"""Test opening navigation overlay with Bookmarks tab active"""
reader = reader_with_book
# Save a bookmark first
reader.save_position("Test Bookmark")
# Open navigation overlay with bookmarks tab
image = reader.open_navigation_overlay(active_tab="bookmarks")
assert image is not None
assert isinstance(image, Image.Image)
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_switch_navigation_tabs(reader_with_book):
"""Test switching between Contents and Bookmarks tabs"""
reader = reader_with_book
# Open with contents tab
reader.open_navigation_overlay(active_tab="contents")
# Switch to bookmarks
image = reader.switch_navigation_tab("bookmarks")
assert image is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
# Switch back to contents
image = reader.switch_navigation_tab("contents")
assert image is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_close_navigation_overlay(reader_with_book):
"""Test closing navigation overlay"""
reader = reader_with_book
# Open overlay
reader.open_navigation_overlay()
assert reader.is_overlay_open()
# Close overlay
image = reader.close_overlay()
assert image is not None
assert not reader.is_overlay_open()
assert reader.get_overlay_state() == OverlayState.NONE
def test_navigation_overlay_tab_switching_gesture(reader_with_book):
"""Test tab switching via gesture/touch handling"""
reader = reader_with_book
# Open navigation overlay
reader.open_navigation_overlay(active_tab="contents")
# Query the overlay to find the bookmarks tab button
# This would normally be done by finding the coordinates of the "Bookmarks" tab
# For now, we test that the switch method works
result = reader.switch_navigation_tab("bookmarks")
assert result is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_navigation_overlay_with_no_bookmarks(reader_with_book):
"""Test navigation overlay when there are no bookmarks"""
reader = reader_with_book
# Open bookmarks tab (should show "No bookmarks yet")
image = reader.open_navigation_overlay(active_tab="bookmarks")
assert image is not None
# The overlay should still open successfully
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_navigation_overlay_preserves_page_position(reader_with_book):
"""Test that opening/closing navigation overlay preserves reading position"""
reader = reader_with_book
# Go to page 2
reader.next_page()
initial_position = reader.get_position_info()
# Open and close navigation overlay
reader.open_navigation_overlay()
reader.close_overlay()
# Verify position hasn't changed
final_position = reader.get_position_info()
assert initial_position == final_position
def test_navigation_overlay_chapter_selection(reader_with_book):
"""Test selecting a chapter from the navigation overlay"""
reader = reader_with_book
# Get chapters
chapters = reader.get_chapters()
if len(chapters) < 2:
pytest.skip("Test book doesn't have enough chapters")
# Open navigation overlay
reader.open_navigation_overlay(active_tab="contents")
# Get initial position
initial_position = reader.get_position_info()
# Jump to chapter via the reader method (simulating a tap on chapter)
reader.jump_to_chapter(chapters[1][1]) # chapters[1] = (title, index)
reader.close_overlay()
# Verify position changed
new_position = reader.get_position_info()
assert new_position != initial_position
def test_navigation_overlay_bookmark_selection(reader_with_book):
"""Test selecting a bookmark from the navigation overlay"""
reader = reader_with_book
# Save a bookmark at page 1
reader.save_position("Bookmark 1")
# Move to a different page
reader.next_page()
position_before = reader.get_position_info()
# Open navigation overlay with bookmarks tab
reader.open_navigation_overlay(active_tab="bookmarks")
# Load the bookmark (simulating a tap on bookmark)
page = reader.load_position("Bookmark 1")
assert page is not None
reader.close_overlay()
# Verify position changed back to bookmark
position_after = reader.get_position_info()
assert position_after != position_before
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+396
View File
@@ -0,0 +1,396 @@
"""
Unit tests for Settings overlay functionality.
Tests the complete workflow of:
1. Opening settings overlay with swipe down gesture
2. Adjusting settings (font size, line spacing, etc.)
3. Live preview updates
4. Closing overlay
"""
import unittest
from pathlib import Path
from dreader import (
EbookReader,
TouchEvent,
GestureType,
ActionType,
OverlayState
)
class TestSettingsOverlay(unittest.TestCase):
"""Test Settings overlay opening, interaction, and closing"""
def setUp(self):
"""Set up test reader with a book"""
import os
import zipfile
self.reader = EbookReader(page_size=(800, 1200))
# Load a test EPUB - use a larger EPUB for spacing tests
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if not epubs:
self.skipTest("No test EPUB files available")
# Prefer larger EPUBs for better testing of spacing changes
# Skip minimal-test.epub as it has too little content
epubs = [e for e in epubs if 'minimal' not in e.name]
if not epubs:
epubs = list(epub_dir.glob('*.epub'))
test_epub = epubs[0]
# Debug logging
print(f"\n=== EPUB Loading Debug Info ===")
print(f"Test EPUB path: {test_epub}")
print(f"Absolute path: {test_epub.absolute()}")
print(f"File exists: {test_epub.exists()}")
print(f"File size: {test_epub.stat().st_size if test_epub.exists() else 'N/A'}")
print(f"Is file: {test_epub.is_file() if test_epub.exists() else 'N/A'}")
print(f"Readable: {os.access(test_epub, os.R_OK) if test_epub.exists() else 'N/A'}")
# Test if it's a valid ZIP
if test_epub.exists():
# Check file magic bytes
with open(test_epub, 'rb') as f:
first_bytes = f.read(10)
print(f"First 10 bytes (hex): {first_bytes.hex()}")
print(f"First 10 bytes (ascii): {first_bytes[:4]}")
print(f"Is PK header: {first_bytes[:2] == b'PK'}")
try:
with zipfile.ZipFile(test_epub, 'r') as zf:
print(f"Valid ZIP: True")
print(f"Files in ZIP: {len(zf.namelist())}")
print(f"First 3 files: {zf.namelist()[:3]}")
except Exception as e:
print(f"ZIP validation error: {e}")
# Try to load
success = self.reader.load_epub(str(test_epub))
if not success:
print(f"=== Load failed ===")
# Try loading with pyWebLayout directly for more detailed error
try:
from pyWebLayout.io.readers.epub_reader import read_epub
book = read_epub(str(test_epub))
print(f"Direct pyWebLayout load: SUCCESS (unexpected!)")
except Exception as e:
print(f"Direct pyWebLayout load error: {e}")
import traceback
traceback.print_exc()
self.assertTrue(success, f"Failed to load test EPUB: {test_epub}")
def tearDown(self):
"""Clean up"""
self.reader.close()
def test_open_settings_overlay_directly(self):
"""Test opening settings overlay using direct API call"""
# Initially no overlay
self.assertFalse(self.reader.is_overlay_open())
# Open settings overlay
overlay_image = self.reader.open_settings_overlay()
# Should return an image
self.assertIsNotNone(overlay_image)
self.assertEqual(overlay_image.size, (800, 1200))
# Overlay should be open
self.assertTrue(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.SETTINGS)
def test_close_settings_overlay_directly(self):
"""Test closing settings overlay using direct API call"""
# Open overlay first
self.reader.open_settings_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Close overlay
page_image = self.reader.close_overlay()
# Should return base page
self.assertIsNotNone(page_image)
# Overlay should be closed
self.assertFalse(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
def test_swipe_down_from_top_opens_settings(self):
"""Test that swipe down from top of screen opens settings overlay"""
# Create swipe down event from top of screen (y=100, which is < 20% of 1200)
event = TouchEvent(
gesture=GestureType.SWIPE_DOWN,
x=400,
y=100
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should open overlay
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
self.assertEqual(response.data['overlay_type'], 'settings')
self.assertTrue(self.reader.is_overlay_open())
def test_swipe_down_from_middle_does_not_open_settings(self):
"""Test that swipe down from middle of screen does NOT open settings"""
# Create swipe down event from middle of screen (y=600, which is > 20% of 1200)
event = TouchEvent(
gesture=GestureType.SWIPE_DOWN,
x=400,
y=600
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should not open overlay
self.assertEqual(response.action, ActionType.NONE)
self.assertFalse(self.reader.is_overlay_open())
def test_tap_outside_closes_settings_overlay(self):
"""Test that tapping outside the settings panel closes it"""
# Open overlay first
self.reader.open_settings_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Tap in the far left (outside the centered panel)
event = TouchEvent(
gesture=GestureType.TAP,
x=50, # Well outside panel
y=600
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should close overlay
self.assertEqual(response.action, ActionType.OVERLAY_CLOSED)
self.assertFalse(self.reader.is_overlay_open())
def test_font_size_increase(self):
"""Test increasing font size through settings overlay"""
# Open overlay
self.reader.open_settings_overlay()
initial_font_scale = self.reader.base_font_scale
# Get overlay reader to query button positions from the active overlay sub-application
overlay_subapp = self.reader._active_overlay
if not overlay_subapp:
self.skipTest("No active overlay sub-application")
overlay_reader = overlay_subapp._overlay_reader
if not overlay_reader or not overlay_reader.manager:
self.skipTest("Overlay reader not available for querying")
# Query the overlay to find the "A+" button link
# We'll search for it by looking for links with "setting:font_increase"
page = overlay_reader.manager.get_current_page()
# Try multiple Y positions in the font size row to find the button
# Panel is 60% of screen width (480px) centered (x offset = 160)
# First setting row should be around y=100-150 in panel coordinates
found_button = False
tap_x = None
tap_y = None
for y in range(80, 180, 10):
for x in range(300, 450, 20): # Right side of panel where buttons are
# Translate to panel coordinates
panel_x_offset = int((800 - 480) / 2)
panel_y_offset = int((1200 - 840) / 2)
panel_x = x - panel_x_offset
panel_y = y - panel_y_offset
if panel_x < 0 or panel_y < 0:
continue
result = page.query_point((panel_x, panel_y))
if result and result.link_target == "setting:font_increase":
tap_x = x
tap_y = y
found_button = True
break
if found_button:
break
if not found_button:
# Fallback: use approximate coordinates
# Based on HTML layout: panel center + right side button
tap_x = 550
tap_y = 350
# Tap the increase button (in screen coordinates)
event = TouchEvent(
gesture=GestureType.TAP,
x=tap_x,
y=tap_y
)
response = self.reader.handle_touch(event)
# Should either change setting or close (depending on whether we hit the button)
if response.action == ActionType.SETTING_CHANGED:
# Font size should have increased
self.assertGreater(self.reader.base_font_scale, initial_font_scale)
# Overlay should still be open
self.assertTrue(self.reader.is_overlay_open())
else:
# If we missed the button, that's OK for this test
pass
def test_line_spacing_adjustment(self):
"""Test adjusting line spacing through settings overlay"""
# Open overlay
self.reader.open_settings_overlay()
initial_spacing = self.reader.page_style.line_spacing
# Close overlay for this test (full interaction would require precise coordinates)
self.reader.close_overlay()
# Verify we can adjust line spacing programmatically
self.reader.set_line_spacing(initial_spacing + 2)
self.assertEqual(self.reader.page_style.line_spacing, initial_spacing + 2)
def test_settings_values_displayed_in_overlay(self):
"""Test that current settings values are shown in the overlay"""
# Set specific values
self.reader.set_font_size(1.5) # 150%
self.reader.set_line_spacing(10)
# Open overlay
overlay_image = self.reader.open_settings_overlay()
self.assertIsNotNone(overlay_image)
# Overlay should be open with current values
# (Visual verification would show "150%" and "10px" in the HTML)
self.assertTrue(self.reader.is_overlay_open())
def test_multiple_setting_changes(self):
"""Test making multiple setting changes in sequence"""
initial_font = self.reader.base_font_scale
initial_spacing = self.reader.page_style.line_spacing
# Change font size
self.reader.increase_font_size()
self.assertNotEqual(self.reader.base_font_scale, initial_font)
# Change line spacing
self.reader.set_line_spacing(initial_spacing + 5)
self.assertNotEqual(self.reader.page_style.line_spacing, initial_spacing)
# Open overlay to verify values
overlay_image = self.reader.open_settings_overlay()
self.assertIsNotNone(overlay_image)
def test_settings_persist_after_overlay_close(self):
"""Test that setting changes persist after closing overlay"""
# Make a change
initial_font = self.reader.base_font_scale
self.reader.increase_font_size()
new_font = self.reader.base_font_scale
# Open and close overlay
self.reader.open_settings_overlay()
self.reader.close_overlay()
# Settings should still be changed
self.assertEqual(self.reader.base_font_scale, new_font)
self.assertNotEqual(self.reader.base_font_scale, initial_font)
def test_overlay_refresh_after_setting_change(self):
"""Test that overlay can be refreshed with updated values"""
# Open overlay
self.reader.open_settings_overlay()
# Access refresh method through active overlay sub-application
overlay_subapp = self.reader._active_overlay
if not overlay_subapp:
self.skipTest("No active overlay sub-application")
# Change a setting programmatically
self.reader.increase_font_size()
new_page = self.reader.get_current_page(include_highlights=False)
# Refresh overlay
refreshed_image = overlay_subapp.refresh(
updated_base_page=new_page,
font_scale=self.reader.base_font_scale,
line_spacing=self.reader.page_style.line_spacing,
inter_block_spacing=self.reader.page_style.inter_block_spacing
)
self.assertIsNotNone(refreshed_image)
self.assertEqual(refreshed_image.size, (800, 1200))
def test_line_spacing_actually_changes_rendering(self):
"""Verify that line spacing changes produce different rendered images"""
# Close any open overlay first
if self.reader.is_overlay_open():
self.reader.close_overlay()
# Set initial line spacing and get page
self.reader.set_line_spacing(5)
page1 = self.reader.get_current_page()
self.assertIsNotNone(page1)
# Change line spacing significantly
self.reader.set_line_spacing(30)
page2 = self.reader.get_current_page()
self.assertIsNotNone(page2)
# Images should be different (different line spacing should affect rendering)
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
"Line spacing change should affect rendering")
def test_inter_block_spacing_actually_changes_rendering(self):
"""Verify that inter-block spacing changes produce different rendered images"""
# Close any open overlay first
if self.reader.is_overlay_open():
self.reader.close_overlay()
# Set initial inter-block spacing and get page
self.reader.set_inter_block_spacing(15)
page1 = self.reader.get_current_page()
self.assertIsNotNone(page1)
# Change inter-block spacing significantly
self.reader.set_inter_block_spacing(50)
page2 = self.reader.get_current_page()
self.assertIsNotNone(page2)
# Images should be different
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
"Inter-block spacing change should affect rendering")
def test_word_spacing_actually_changes_rendering(self):
"""Verify that word spacing changes produce different rendered images"""
# Close any open overlay first
if self.reader.is_overlay_open():
self.reader.close_overlay()
# Set initial word spacing and get page
self.reader.set_word_spacing(0)
page1 = self.reader.get_current_page()
self.assertIsNotNone(page1)
# Change word spacing significantly
self.reader.set_word_spacing(20)
page2 = self.reader.get_current_page()
self.assertIsNotNone(page2)
# Images should be different
self.assertNotEqual(page1.tobytes(), page2.tobytes(),
"Word spacing change should affect rendering")
if __name__ == '__main__':
unittest.main()
+456
View File
@@ -0,0 +1,456 @@
"""
Unit tests for TOC overlay functionality.
Tests the complete workflow of:
1. Opening TOC overlay with swipe up gesture
2. Selecting a chapter from the TOC
3. Closing overlay by tapping outside or swiping down
"""
import unittest
from pathlib import Path
from dreader import (
EbookReader,
TouchEvent,
GestureType,
ActionType,
OverlayState
)
class TestTOCOverlay(unittest.TestCase):
"""Test TOC overlay opening, interaction, and closing"""
def setUp(self):
"""Set up test reader with a book"""
self.reader = EbookReader(page_size=(800, 1200))
# Load a test EPUB
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
if not test_epub.exists():
# Try to find any EPUB in test data
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if epubs:
test_epub = epubs[0]
else:
self.skipTest("No test EPUB files available")
success = self.reader.load_epub(str(test_epub))
self.assertTrue(success, "Failed to load test EPUB")
def tearDown(self):
"""Clean up"""
self.reader.close()
def test_overlay_manager_initialization(self):
"""Test that overlay sub-applications are properly initialized"""
# Check that overlay sub-applications exist
self.assertIsNotNone(self.reader._overlay_subapps)
self.assertIn(OverlayState.TOC, self.reader._overlay_subapps)
self.assertIn(OverlayState.SETTINGS, self.reader._overlay_subapps)
self.assertIn(OverlayState.NAVIGATION, self.reader._overlay_subapps)
# Initially no overlay should be active
self.assertFalse(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
def test_open_toc_overlay_directly(self):
"""Test opening TOC overlay using direct API call"""
# Initially no overlay
self.assertFalse(self.reader.is_overlay_open())
# Open TOC overlay
overlay_image = self.reader.open_toc_overlay()
# Should return an image
self.assertIsNotNone(overlay_image)
self.assertEqual(overlay_image.size, (800, 1200))
# Overlay should be open
self.assertTrue(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.TOC)
def test_close_toc_overlay_directly(self):
"""Test closing TOC overlay using direct API call"""
# Open overlay first
self.reader.open_toc_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Close overlay
page_image = self.reader.close_overlay()
# Should return base page
self.assertIsNotNone(page_image)
# Overlay should be closed
self.assertFalse(self.reader.is_overlay_open())
self.assertEqual(self.reader.get_overlay_state(), OverlayState.NONE)
def test_swipe_up_from_bottom_opens_toc(self):
"""Test that swipe up from bottom of screen opens TOC overlay"""
# Create swipe up event from bottom of screen (y=1100, which is > 80% of 1200)
event = TouchEvent(
gesture=GestureType.SWIPE_UP,
x=400,
y=1100
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should open overlay (navigation or toc, depending on implementation)
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
self.assertTrue(self.reader.is_overlay_open())
def test_swipe_up_from_middle_opens_navigation(self):
"""Test that swipe up from anywhere opens navigation overlay"""
# Create swipe up event from middle of screen
event = TouchEvent(
gesture=GestureType.SWIPE_UP,
x=400,
y=600
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should open navigation overlay from anywhere
self.assertEqual(response.action, ActionType.OVERLAY_OPENED)
self.assertIn(response.data['overlay_type'], ['toc', 'navigation'])
self.assertTrue(self.reader.is_overlay_open())
def test_swipe_down_closes_overlay(self):
"""Test that swipe down closes the overlay"""
# Open overlay first
self.reader.open_toc_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Create swipe down event
event = TouchEvent(
gesture=GestureType.SWIPE_DOWN,
x=400,
y=300
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should close overlay
self.assertEqual(response.action, ActionType.OVERLAY_CLOSED)
self.assertFalse(self.reader.is_overlay_open())
def test_tap_outside_overlay_closes_it(self):
"""Test that tapping outside the overlay panel closes it"""
# Open overlay first
self.reader.open_toc_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Tap in the far left (outside the centered panel)
# Panel is 60% wide centered, so left edge is at 20%
event = TouchEvent(
gesture=GestureType.TAP,
x=50, # Well outside panel
y=600
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should close overlay
self.assertEqual(response.action, ActionType.OVERLAY_CLOSED)
self.assertFalse(self.reader.is_overlay_open())
def test_tap_on_chapter_selects_and_closes(self):
"""Test that tapping on a chapter navigates to it and closes overlay"""
# Open overlay first
self.reader.open_toc_overlay()
chapters = self.reader.get_chapters()
if len(chapters) < 2:
self.skipTest("Need at least 2 chapters for this test")
# Calculate tap position for second chapter (index 1 - "Metamorphosis")
# Based on actual measurements from pyWebLayout query_point:
# Overlay bounds: (38, 138, 122, 16) -> X=[38,160], Y=[138,154]
# With panel offset (160, 180): Screen X=[198,320], Y=[318,334]
tap_x = 250 # Within the link text bounds
tap_y = 335 # Chapter 1 "Metamorphosis" at overlay Y=155 (138+16=154, screen 180+155=335)
event = TouchEvent(
gesture=GestureType.TAP,
x=tap_x,
y=tap_y
)
# Handle gesture
response = self.reader.handle_touch(event)
# Should select chapter
self.assertEqual(response.action, ActionType.CHAPTER_SELECTED)
self.assertIn('chapter_index', response.data)
# Overlay should be closed
self.assertFalse(self.reader.is_overlay_open())
def test_multiple_overlay_operations(self):
"""Test opening and closing overlay multiple times"""
# Open and close 3 times
for i in range(3):
# Open
self.reader.open_toc_overlay()
self.assertTrue(self.reader.is_overlay_open())
# Close
self.reader.close_overlay()
self.assertFalse(self.reader.is_overlay_open())
def test_overlay_with_page_navigation(self):
"""Test that overlay works correctly after navigating pages"""
# Navigate to page 2
self.reader.next_page()
# Open overlay
overlay_image = self.reader.open_toc_overlay()
self.assertIsNotNone(overlay_image)
self.assertTrue(self.reader.is_overlay_open())
# Close overlay
self.reader.close_overlay()
self.assertFalse(self.reader.is_overlay_open())
def test_toc_overlay_contains_all_chapters(self):
"""Test that TOC overlay includes all book chapters"""
chapters = self.reader.get_chapters()
# Open overlay (this generates the HTML with chapters)
overlay_image = self.reader.open_toc_overlay()
self.assertIsNotNone(overlay_image)
# Verify overlay manager has correct chapter count
# This is implicit in the rendering - if it renders without error,
# all chapters were included
self.assertTrue(self.reader.is_overlay_open())
def test_overlay_state_persistence_ready(self):
"""Test that overlay state can be tracked (for future state persistence)"""
# This test verifies the state tracking is ready for StateManager integration
# Start with no overlay
self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE)
# Open TOC
self.reader.open_toc_overlay()
self.assertEqual(self.reader.current_overlay_state, OverlayState.TOC)
# Close overlay
self.reader.close_overlay()
self.assertEqual(self.reader.current_overlay_state, OverlayState.NONE)
class TestOverlayRendering(unittest.TestCase):
"""Test overlay rendering and compositing"""
def setUp(self):
"""Set up test reader"""
self.reader = EbookReader(page_size=(800, 1200))
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
if not test_epub.exists():
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if epubs:
test_epub = epubs[0]
else:
self.skipTest("No test EPUB files available")
self.reader.load_epub(str(test_epub))
def tearDown(self):
"""Clean up"""
self.reader.close()
def test_overlay_image_size(self):
"""Test that overlay image matches page size"""
overlay_image = self.reader.open_toc_overlay()
self.assertEqual(overlay_image.size, (800, 1200))
def test_overlay_compositing(self):
"""Test that overlay is properly composited on base page"""
# Get base page
base_page = self.reader.get_current_page()
# Open overlay (creates composited image)
overlay_image = self.reader.open_toc_overlay()
# Composited image should be different from base page
self.assertIsNotNone(overlay_image)
# Images should have same size but different content
self.assertEqual(base_page.size, overlay_image.size)
def test_overlay_html_to_image_conversion(self):
"""Test that HTML overlay is correctly converted to image"""
from dreader.html_generator import generate_toc_overlay
# Get chapters
chapters = self.reader.get_chapters()
chapter_data = [{"index": idx, "title": title} for title, idx in chapters]
# Generate HTML
html = generate_toc_overlay(chapter_data)
self.assertIsNotNone(html)
self.assertIn("Table of Contents", html)
# Open the TOC overlay which internally renders HTML to image
overlay_image = self.reader.open_toc_overlay()
# Should produce valid image
self.assertIsNotNone(overlay_image)
self.assertEqual(overlay_image.size, (800, 1200))
class TestTOCPagination(unittest.TestCase):
"""Test TOC overlay pagination functionality"""
def setUp(self):
"""Set up test reader with a book"""
self.reader = EbookReader(page_size=(800, 1200))
# Load a test EPUB
test_epub = Path(__file__).parent / 'data' / 'library-epub' / 'alice.epub'
if not test_epub.exists():
epub_dir = Path(__file__).parent / 'data' / 'library-epub'
epubs = list(epub_dir.glob('*.epub'))
if epubs:
test_epub = epubs[0]
else:
self.skipTest("No test EPUB files available")
success = self.reader.load_epub(str(test_epub))
self.assertTrue(success, "Failed to load test EPUB")
def tearDown(self):
"""Clean up"""
self.reader.close()
def test_pagination_with_many_chapters(self):
"""Test pagination when there are more chapters than fit on one page"""
from dreader.html_generator import generate_toc_overlay
# Create test data with many chapters
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
# Generate HTML for page 1 (chapters 0-9)
html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
self.assertIn("1. Chapter 1", html_page1)
self.assertIn("10. Chapter 10", html_page1)
self.assertNotIn("11. Chapter 11", html_page1)
self.assertIn("Page 1 of 3", html_page1)
# Generate HTML for page 2 (chapters 10-19)
html_page2 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=1, toc_items_per_page=10)
self.assertNotIn("10. Chapter 10", html_page2)
self.assertIn("11. Chapter 11", html_page2)
self.assertIn("20. Chapter 20", html_page2)
self.assertIn("Page 2 of 3", html_page2)
# Generate HTML for page 3 (chapters 20-24)
html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10)
self.assertNotIn("20. Chapter 20", html_page3)
self.assertIn("21. Chapter 21", html_page3)
self.assertIn("25. Chapter 25", html_page3)
self.assertIn("Page 3 of 3", html_page3)
def test_pagination_buttons_disabled_at_boundaries(self):
"""Test that pagination buttons are disabled at first and last pages"""
from dreader.html_generator import generate_toc_overlay
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
# Page 1: prev button should be disabled
html_page1 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
self.assertIn("page:prev", html_page1)
self.assertIn("page:next", html_page1)
# Check that prev button has disabled styling
self.assertIn("opacity: 0.3; pointer-events: none;", html_page1)
# Last page: next button should be disabled
html_page3 = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=2, toc_items_per_page=10)
self.assertIn("page:prev", html_page3)
self.assertIn("page:next", html_page3)
def test_no_pagination_for_small_list(self):
"""Test that pagination is not shown when all chapters fit on one page"""
from dreader.html_generator import generate_toc_overlay
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)]
html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
self.assertNotIn("page:prev", html)
self.assertNotIn("page:next", html)
self.assertNotIn("Page", html.split("chapters")[1]) # No "Page X of Y" after "N chapters"
def test_navigation_overlay_pagination(self):
"""Test pagination in the modern navigation overlay"""
from dreader.html_generator import generate_navigation_overlay
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(25)]
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(15)]
# Generate navigation overlay with pagination
html = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="contents",
page_size=(800, 1200),
toc_page=1,
toc_items_per_page=10,
bookmarks_page=0
)
# Should show chapters 11-20 on page 2
self.assertIn("11. Chapter 11", html)
self.assertIn("20. Chapter 20", html)
self.assertNotIn("10. Chapter 10", html)
self.assertNotIn("21. Chapter 21", html)
def test_bookmarks_pagination(self):
"""Test pagination works for bookmarks tab too"""
from dreader.html_generator import generate_navigation_overlay
chapters = [{"index": i, "title": f"Chapter {i+1}"} for i in range(5)]
bookmarks = [{"name": f"Bookmark {i+1}", "position": f"Page {i}"} for i in range(25)]
# Generate navigation overlay with bookmarks on page 2
html = generate_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
active_tab="bookmarks",
page_size=(800, 1200),
toc_page=0,
toc_items_per_page=10,
bookmarks_page=1
)
# Should show bookmarks 11-20 on page 2
self.assertIn("Bookmark 11", html)
self.assertIn("Bookmark 20", html)
self.assertNotIn("Bookmark 10", html)
self.assertNotIn("Bookmark 21", html)
def test_pagination_handles_empty_list(self):
"""Test pagination handles empty chapter list gracefully"""
from dreader.html_generator import generate_toc_overlay
chapters = []
html = generate_toc_overlay(chapters, page_size=(800, 1200), toc_page=0, toc_items_per_page=10)
self.assertIn("0 chapters", html)
self.assertNotIn("page:prev", html)
self.assertNotIn("page:next", html)
if __name__ == '__main__':
unittest.main()
+1
View File
@@ -0,0 +1 @@
"""Unit tests for dreader modules."""
+1
View File
@@ -0,0 +1 @@
"""Unit tests for manager modules."""
+164
View File
@@ -0,0 +1,164 @@
"""
Unit tests for DocumentManager.
Tests document loading in isolation without full EbookReader.
"""
import unittest
import tempfile
import os
from pathlib import Path
from dreader.managers.document import DocumentManager
class TestDocumentManager(unittest.TestCase):
"""Test DocumentManager in isolation"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
self.manager = DocumentManager()
def tearDown(self):
"""Clean up"""
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_initialization(self):
"""Test manager initializes correctly"""
manager = DocumentManager()
self.assertIsNone(manager.document_id)
self.assertIsNone(manager.title)
self.assertIsNone(manager.author)
self.assertIsNone(manager.blocks)
self.assertFalse(manager.is_loaded())
def test_load_valid_epub(self):
"""Test loading a valid EPUB file"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
success = self.manager.load_epub(self.epub_path)
self.assertTrue(success)
self.assertTrue(self.manager.is_loaded())
self.assertIsNotNone(self.manager.document_id)
self.assertIsNotNone(self.manager.title)
self.assertIsNotNone(self.manager.author)
self.assertIsNotNone(self.manager.blocks)
self.assertGreater(len(self.manager.blocks), 0)
def test_load_nonexistent_epub(self):
"""Test loading a non-existent EPUB file"""
success = self.manager.load_epub("nonexistent.epub")
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_load_invalid_epub(self):
"""Test loading an invalid file as EPUB"""
# Create a temporary invalid file
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
with open(invalid_path, 'w') as f:
f.write("This is not a valid EPUB file")
success = self.manager.load_epub(invalid_path)
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_load_html_success(self):
"""Test loading HTML content"""
html = """
<html>
<body>
<h1>Test Document</h1>
<p>This is a test paragraph.</p>
</body>
</html>
"""
success = self.manager.load_html(
html,
title="Test HTML",
author="Test Author",
document_id="test_html"
)
self.assertTrue(success)
self.assertTrue(self.manager.is_loaded())
self.assertEqual(self.manager.title, "Test HTML")
self.assertEqual(self.manager.author, "Test Author")
self.assertEqual(self.manager.document_id, "test_html")
self.assertGreater(len(self.manager.blocks), 0)
def test_load_empty_html(self):
"""Test loading empty HTML"""
success = self.manager.load_html("")
self.assertFalse(success)
self.assertFalse(self.manager.is_loaded())
def test_get_metadata(self):
"""Test getting document metadata"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
metadata = self.manager.get_metadata()
self.assertIsInstance(metadata, dict)
self.assertIn('title', metadata)
self.assertIn('author', metadata)
self.assertIn('document_id', metadata)
self.assertIn('total_blocks', metadata)
self.assertGreater(metadata['total_blocks'], 0)
def test_get_blocks(self):
"""Test getting content blocks"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
blocks = self.manager.get_blocks()
self.assertIsInstance(blocks, list)
self.assertGreater(len(blocks), 0)
def test_clear(self):
"""Test clearing loaded document"""
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
self.manager.load_epub(self.epub_path)
self.assertTrue(self.manager.is_loaded())
self.manager.clear()
self.assertFalse(self.manager.is_loaded())
self.assertIsNone(self.manager.document_id)
self.assertIsNone(self.manager.title)
self.assertIsNone(self.manager.author)
self.assertIsNone(self.manager.blocks)
def test_multiple_loads(self):
"""Test loading multiple documents sequentially"""
html1 = "<html><body><h1>Document 1</h1></body></html>"
html2 = "<html><body><h1>Document 2</h1></body></html>"
# Load first document
self.manager.load_html(html1, title="Doc 1", document_id="doc1")
self.assertEqual(self.manager.title, "Doc 1")
self.assertEqual(self.manager.document_id, "doc1")
# Load second document (should replace first)
self.manager.load_html(html2, title="Doc 2", document_id="doc2")
self.assertEqual(self.manager.title, "Doc 2")
self.assertEqual(self.manager.document_id, "doc2")
if __name__ == '__main__':
unittest.main()
+220
View File
@@ -0,0 +1,220 @@
"""
Unit tests for SettingsManager.
Tests settings management in isolation using mocks.
"""
import unittest
from unittest.mock import Mock, MagicMock
from dreader.managers.settings import SettingsManager
class TestSettingsManager(unittest.TestCase):
"""Test SettingsManager in isolation"""
def setUp(self):
"""Set up test environment"""
self.manager = SettingsManager()
def test_initialization(self):
"""Test manager initializes correctly"""
manager = SettingsManager()
self.assertEqual(manager.font_scale, 1.0)
self.assertEqual(manager.font_scale_step, 0.1)
self.assertIsNone(manager.manager)
def test_get_font_size(self):
"""Test getting current font size"""
self.assertEqual(self.manager.get_font_size(), 1.0)
self.manager.font_scale = 1.5
self.assertEqual(self.manager.get_font_size(), 1.5)
def test_set_font_size_without_manager(self):
"""Test setting font size without layout manager returns None"""
result = self.manager.set_font_size(1.5)
self.assertIsNone(result)
def test_set_font_size_with_manager(self):
"""Test setting font size with layout manager"""
# Create mock manager
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
# Set font size
result = self.manager.set_font_size(1.5)
self.assertIsNotNone(result)
self.assertEqual(result, "rendered_page")
self.assertEqual(self.manager.font_scale, 1.5)
mock_layout_manager.set_font_scale.assert_called_once_with(1.5)
def test_font_size_clamping(self):
"""Test font size is clamped between 0.5x and 3.0x"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
# Test upper bound
self.manager.set_font_size(5.0)
self.assertEqual(self.manager.font_scale, 3.0)
# Test lower bound
self.manager.set_font_size(0.1)
self.assertEqual(self.manager.font_scale, 0.5)
def test_increase_font_size(self):
"""Test increasing font size by one step"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
initial_size = self.manager.font_scale
self.manager.increase_font_size()
self.assertEqual(self.manager.font_scale, initial_size + 0.1)
def test_decrease_font_size(self):
"""Test decreasing font size by one step"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.font_scale = 1.5
self.manager.set_manager(mock_layout_manager)
self.manager.font_scale = 1.5
self.manager.decrease_font_size()
self.assertAlmostEqual(self.manager.font_scale, 1.4, places=5)
def test_set_line_spacing(self):
"""Test setting line spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 5
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_line_spacing(10)
self.assertIsNotNone(result)
mock_layout_manager.increase_line_spacing.assert_called_once_with(5)
def test_set_inter_block_spacing(self):
"""Test setting inter-block spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.inter_block_spacing = 15
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_inter_block_spacing(25)
self.assertIsNotNone(result)
mock_layout_manager.increase_inter_block_spacing.assert_called_once_with(10)
def test_set_word_spacing(self):
"""Test setting word spacing"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.word_spacing = 0
self.manager.set_manager(mock_layout_manager)
result = self.manager.set_word_spacing(3)
self.assertIsNotNone(result)
mock_layout_manager.increase_word_spacing.assert_called_once_with(3)
def test_get_current_settings_without_manager(self):
"""Test getting settings without layout manager"""
settings = self.manager.get_current_settings()
self.assertIsInstance(settings, dict)
self.assertEqual(settings['font_scale'], 1.0)
self.assertEqual(settings['line_spacing'], 5)
self.assertEqual(settings['inter_block_spacing'], 15)
self.assertEqual(settings['word_spacing'], 0)
def test_get_current_settings_with_manager(self):
"""Test getting settings with layout manager"""
mock_layout_manager = Mock()
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 10
mock_layout_manager.page_style.inter_block_spacing = 20
mock_layout_manager.page_style.word_spacing = 3
mock_layout_manager.font_scale = 1.5
self.manager.set_manager(mock_layout_manager)
self.manager.font_scale = 1.5
settings = self.manager.get_current_settings()
self.assertEqual(settings['font_scale'], 1.5)
self.assertEqual(settings['line_spacing'], 10)
self.assertEqual(settings['inter_block_spacing'], 20)
self.assertEqual(settings['word_spacing'], 3)
def test_apply_settings(self):
"""Test applying settings from dictionary"""
mock_layout_manager = Mock()
mock_page = Mock()
mock_page.render.return_value = "rendered_page"
mock_layout_manager.set_font_scale.return_value = mock_page
mock_layout_manager.get_current_page.return_value = mock_page
mock_layout_manager.page_style = Mock()
mock_layout_manager.page_style.line_spacing = 5
mock_layout_manager.page_style.inter_block_spacing = 15
mock_layout_manager.page_style.word_spacing = 0
mock_layout_manager.font_scale = 1.0
self.manager.set_manager(mock_layout_manager)
settings = {
'font_scale': 1.5,
'line_spacing': 10,
'inter_block_spacing': 20,
'word_spacing': 3
}
success = self.manager.apply_settings(settings)
self.assertTrue(success)
self.assertEqual(self.manager.font_scale, 1.5)
def test_apply_settings_without_manager(self):
"""Test applying settings without layout manager returns False"""
settings = {'font_scale': 1.5}
success = self.manager.apply_settings(settings)
self.assertFalse(success)
if __name__ == '__main__':
unittest.main()