Update coverage badges [skip ci]
This commit is contained in:
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.
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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!")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for dreader modules."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Unit tests for manager modules."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user