large change to allow project merging
Some checks failed
Python CI / test (push) Successful in 55s
Lint / lint (push) Successful in 1m31s
Tests / test (3.10) (push) Failing after 44s
Tests / test (3.11) (push) Failing after 42s
Tests / test (3.9) (push) Failing after 42s

This commit is contained in:
Duncan Tourolle 2025-11-23 00:33:42 +01:00
parent e93a789524
commit 0d698a83b4
62 changed files with 2939 additions and 606 deletions

541
MERGE_FEATURE.md Normal file
View File

@ -0,0 +1,541 @@
# Project Merge & Conflict Resolution Feature
## Overview
pyPhotoAlbum v3.0 introduces comprehensive merge conflict resolution support, enabling multiple users to edit the same album and merge their changes intelligently. The system uses UUIDs, timestamps, and a project ID to track changes and resolve conflicts.
## Table of Contents
- [Key Features](#key-features)
- [How It Works](#how-it-works)
- [File Format Changes (v3.0)](#file-format-changes-v30)
- [User Guide](#user-guide)
- [Developer Guide](#developer-guide)
- [Testing](#testing)
- [Migration from v2.0](#migration-from-v20)
---
## Key Features
### 1. **Project ID-Based Merge Detection**
- Each project has a unique `project_id` (UUID)
- **Same project_id** → Merge with conflict resolution
- **Different project_id** → Concatenate (combine all pages)
### 2. **UUID-Based Element Tracking**
- Every page and element has a stable UUID
- Elements can be tracked even when page numbers or z-order changes
- Enables reliable conflict detection across versions
### 3. **Timestamp-Based Conflict Resolution**
- All changes tracked with `created` and `last_modified` timestamps (ISO 8601 UTC)
- Automatic "Latest Wins" strategy available
- Manual conflict resolution through visual dialog
### 4. **Soft Delete Support**
- Deleted items marked with `deleted` flag and `deleted_at` timestamp
- Prevents resurrection conflicts
- Tombstone pattern ensures deleted items stay deleted
### 5. **Visual Merge Dialog**
- Side-by-side comparison of conflicting changes
- Page previews and element details
- Multiple resolution strategies:
- **Latest Wins**: Most recent change wins (automatic)
- **Always Use Yours**: Keep all local changes
- **Always Use Theirs**: Accept all remote changes
- **Manual**: Choose per-conflict
---
## How It Works
### Merge Workflow
```
1. User clicks "Merge Projects" in File ribbon tab
2. Select .ppz file to merge
3. System compares project_ids
├─→ Same ID: Detect conflicts → Show merge dialog
└─→ Different ID: Ask to concatenate
4. User resolves conflicts (if any)
5. Merged project becomes current project
6. User saves merged project
```
### Conflict Detection
The system detects three types of conflicts:
#### 1. **Page-Level Conflicts**
- Page modified in both versions
- Page deleted in one, modified in other
- Page properties changed (size, type, etc.)
#### 2. **Element-Level Conflicts**
- Element modified in both versions (position, size, rotation, content)
- Element deleted in one, modified in other
- Element properties changed differently
#### 3. **Project-Level Conflicts**
- Settings changed in both (page size, DPI, cover settings, etc.)
### Automatic Conflict Resolution
**Non-conflicting changes** are automatically merged:
- Page 1 modified in version A, Page 2 modified in version B → Keep both
- New pages added at different positions → Merge both sets
- Different elements modified → Keep all modifications
**Conflicting changes** require resolution:
- Same element modified in both versions
- Element/page deleted in one but modified in other
---
## File Format Changes (v3.0)
### What's New in v3.0
#### Project Level
```json
{
"data_version": "3.0",
"project_id": "550e8400-e29b-41d4-a716-446655440000",
"created": "2025-01-22T10:30:00.123456+00:00",
"last_modified": "2025-01-22T14:45:12.789012+00:00",
...
}
```
#### Page Level
```json
{
"page_number": 1,
"uuid": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"created": "2025-01-22T10:30:00.123456+00:00",
"last_modified": "2025-01-22T11:15:30.456789+00:00",
"deleted": false,
"deleted_at": null,
...
}
```
#### Element Level
```json
{
"type": "image",
"uuid": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"created": "2025-01-22T10:30:00.123456+00:00",
"last_modified": "2025-01-22T13:20:45.123456+00:00",
"deleted": false,
"deleted_at": null,
"position": [10, 10],
"size": [100, 100],
...
}
```
### Backwards Compatibility
- **v3.0 can read v2.0 and v1.0 files** with automatic migration
- **v2.0/v1.0 cannot read v3.0 files** (breaking change)
- Migration automatically generates UUIDs and timestamps for old files
---
## User Guide
### How to Merge Two Album Versions
1. **Open your current album** in pyPhotoAlbum
2. **Click "Merge Projects"** in the File tab of the ribbon
3. **Select the other album file** (.ppz) to merge
4. **System analyzes the projects:**
- If they're the same album (same project_id):
- Shows conflicts requiring resolution
- Auto-merges non-conflicting changes
- If they're different albums:
- Asks if you want to combine all pages
5. **Resolve conflicts** (if merging same album):
- View side-by-side comparison
- Choose "Use Your Version" or "Use Other Version" for each conflict
- Or click "Auto-Resolve All" with a strategy:
- **Latest Wins**: Keeps most recently modified version
- **Always Use Yours**: Keeps all your changes
- **Always Use Theirs**: Accepts all their changes
6. **Click "Apply Merge"** to complete the merge
7. **Save the merged album** when ready
### Best Practices
1. **Save before merging** - The system will prompt you, but it's good practice
2. **Use cloud sync carefully** - If using Dropbox/Google Drive:
- Each person should have their own working copy
- Merge explicitly rather than relying on cloud sync conflicts
3. **Communicate with collaborators** - Agree on who edits which pages to minimize conflicts
4. **Review the merge** - Check the merged result before saving
5. **Keep backups** - The autosave system creates checkpoints, but manual backups are recommended
### Common Scenarios
#### Scenario 1: You and a Friend Edit Different Pages
- **Result**: Auto-merge ✅
- No conflicts, both sets of changes preserved
#### Scenario 2: You Both Edit the Same Image Position
- **Result**: Conflict resolution needed ⚠️
- You choose which position to keep
#### Scenario 3: You Delete an Image, They Move It
- **Result**: Conflict resolution needed ⚠️
- You choose: keep it deleted or use their moved version
#### Scenario 4: Combining Two Different Albums
- **Result**: Concatenation
- All pages from both albums combined into one
---
## Developer Guide
### Architecture
```
pyPhotoAlbum/
├── models.py # BaseLayoutElement with UUID/timestamp support
├── project.py # Project and Page with UUID/timestamp support
├── version_manager.py # v3.0 migration logic
├── project_serializer.py # Save/load with v3.0 support
├── merge_manager.py # Core merge conflict detection & resolution
├── merge_dialog.py # Qt UI for visual conflict resolution
└── mixins/operations/
└── merge_ops.py # Ribbon integration & workflow
```
### Key Classes
#### MergeManager
```python
from pyPhotoAlbum.merge_manager import MergeManager, MergeStrategy
manager = MergeManager()
# Check if projects should be merged or concatenated
should_merge = manager.should_merge_projects(project_a_data, project_b_data)
# Detect conflicts
conflicts = manager.detect_conflicts(our_data, their_data)
# Auto-resolve
resolutions = manager.auto_resolve_conflicts(MergeStrategy.LATEST_WINS)
# Apply merge
merged_data = manager.apply_resolutions(our_data, their_data, resolutions)
```
#### Data Model Updates
```python
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project import Page, Project
# All elements now have:
element = ImageData(...)
element.uuid # Auto-generated UUID
element.created # ISO 8601 timestamp
element.last_modified # ISO 8601 timestamp
element.deleted # Boolean flag
element.deleted_at # Timestamp when deleted
# Mark as modified
element.mark_modified() # Updates last_modified
# Mark as deleted
element.mark_deleted() # Sets deleted=True, deleted_at=now
# Same for pages and projects
page.mark_modified()
project.mark_modified()
```
### Adding Merge Support to Custom Elements
If you create custom element types, ensure they:
1. **Inherit from BaseLayoutElement**
```python
class MyCustomElement(BaseLayoutElement):
def __init__(self, **kwargs):
super().__init__(**kwargs) # Initializes UUID and timestamps
# Your custom fields here
```
2. **Call `_deserialize_base_fields()` first in deserialize**
```python
def deserialize(self, data: Dict[str, Any]):
self._deserialize_base_fields(data) # Load UUID/timestamps
# Load your custom fields
```
3. **Include base fields in serialize**
```python
def serialize(self) -> Dict[str, Any]:
data = {
"type": "mycustom",
# Your custom fields
}
data.update(self._serialize_base_fields()) # Add UUID/timestamps
return data
```
4. **Call `mark_modified()` when changed**
```python
def set_my_property(self, value):
self.my_property = value
self.mark_modified() # Update timestamp
```
### Migration System
To add a new migration (e.g., v3.0 to v4.0):
```python
# In version_manager.py
@DataMigration.register_migration("3.0", "4.0")
def migrate_3_0_to_4_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate from version 3.0 to 4.0.
Main changes:
- Add new fields
- Update structures
"""
# Perform migration
data['new_field'] = default_value
# Update version
data['data_version'] = "4.0"
return data
```
### Testing
Run the provided test scripts:
```bash
# Test v2.0 → v3.0 migration
python test_migration.py
# Test merge functionality
python test_merge.py
```
Expected output: All tests should pass with ✅
---
## Testing
### Manual Testing Checklist
#### Test 1: Basic Migration
- [ ] Open a v2.0 project
- [ ] Verify it loads without errors
- [ ] Check console for "Migration 2.0 → 3.0" message
- [ ] Save the project
- [ ] Verify saved version is 3.0
#### Test 2: Same Project Merge
- [ ] Create a project, save it
- [ ] Open the file twice in different instances
- [ ] Modify same element in both
- [ ] Merge them
- [ ] Verify conflict dialog appears
- [ ] Resolve conflict
- [ ] Verify merged result
#### Test 3: Different Project Concatenation
- [ ] Create two different projects
- [ ] Try to merge them
- [ ] Verify concatenation option appears
- [ ] Verify combined project has all pages
#### Test 4: Auto-Merge Non-Conflicting
- [ ] Create project with 2 pages
- [ ] Version A: Edit page 1
- [ ] Version B: Edit page 2
- [ ] Merge
- [ ] Verify auto-merge without conflicts
- [ ] Verify both edits preserved
### Automated Testing
Run the test scripts:
```bash
cd /home/dtourolle/Development/pyPhotoAlbum
# Migration test
./test_migration.py
# Merge test
./test_merge.py
```
---
## Migration from v2.0
### Automatic Migration
When you open a v2.0 project in v3.0, it will automatically:
1. Generate a unique `project_id`
2. Generate `uuid` for all pages and elements
3. Set `created` and `last_modified` to current time
4. Add `deleted` and `deleted_at` fields (all set to False/None)
5. Update `data_version` to "3.0"
### Migration Output Example
```
Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id
Generated project_id: 550e8400-e29b-41d4-a716-446655440000
Migrated 5 pages to v3.0
Migration completed successfully
```
### After Migration
- **Save the project** to persist the migration
- The migrated file can **only be opened in v3.0+**
- Keep a backup of v2.0 file if you need v2.0 compatibility
### Rollback
If you need to rollback to v2.0:
1. Don't save after opening in v3.0
2. Close without saving
3. Open original v2.0 file in v2.0
---
## Troubleshooting
### Merge Dialog Won't Appear
**Problem**: Clicking "Merge Projects" does nothing
**Solutions**:
- Check both projects are v3.0 (or were migrated)
- Verify projects have the same `project_id`
- Check console for error messages
### Can't Resolve Conflicts
**Problem**: "Apply Merge" button is grayed out
**Solutions**:
- Make a resolution choice for each conflict
- Or click "Auto-Resolve All" first
### Changes Not Preserved
**Problem**: After merge, some changes are missing
**Solutions**:
- Check which resolution strategy you used
- "Latest Wins" prefers most recent modifications
- Review each conflict manually if needed
### Project Won't Load
**Problem**: "Incompatible file version" error
**Solutions**:
- This is a v2.0 or v1.0 file
- Migration should happen automatically
- If not, check version_manager.py for errors
---
## FAQ
### Q: Can I merge more than two projects at once?
**A:** Not directly. Merge two at a time, then merge the result with a third.
### Q: What happens to undo history after merge?
**A:** Undo history is session-specific and not preserved during merge. Save before merging.
### Q: Can I see what changed before merging?
**A:** The merge dialog shows changed elements with timestamps. Future versions may add detailed diff view.
### Q: Is merge atomic?
**A:** No. If you cancel during conflict resolution, no changes are made. Once you click "Apply Merge", the changes are applied to the current project.
### Q: Can I merge projects from different versions?
**A:** Yes! v2.0 and v1.0 projects are automatically migrated to v3.0 before merging.
### Q: What if two people add the same image?
**A:** If the image has the same filename and is added to different pages, both instances are kept. If added to the same location on the same page, it becomes a conflict.
### Q: Can I programmatically merge projects?
**A:** Yes! See the Developer Guide section for `MergeManager` API usage.
---
## Future Enhancements
Potential improvements for future versions:
1. **Three-way merge** - Use base version for better conflict resolution
2. **Merge history tracking** - Log all merges performed
3. **Partial merge** - Merge only specific pages
4. **Cloud collaboration** - Real-time collaborative editing
5. **Merge preview** - Show full diff before applying
6. **Asset conflict handling** - Better handling of duplicate assets
7. **Conflict visualization** - Visual overlay showing changes
---
## Version History
### v3.0 (2025-01-22)
- ✨ Initial merge conflict resolution feature
- ✨ UUID and timestamp tracking
- ✨ Project ID-based merge detection
- ✨ Visual merge dialog
- ✨ Automatic migration from v2.0
- ✨ Soft delete support
---
## Credits
Merge system designed and implemented with the following principles:
- **UUID stability** - Elements tracked across versions
- **Timestamp precision** - ISO 8601 UTC for reliable ordering
- **Backwards compatibility** - Seamless migration from v2.0
- **User-friendly** - Visual conflict resolution
- **Developer-friendly** - Clean API, well-documented
For questions or issues, please file a bug report in the project repository.

View File

@ -336,10 +336,10 @@ if not success:
print(f"Error saving: {error}")
# Load project
loaded_project, error = load_from_zip("album.ppz")
if loaded_project:
try:
loaded_project = load_from_zip("album.ppz")
print(f"Loaded: {loaded_project.name}")
else:
except Exception as error:
print(f"Error loading: {error}")
# Get project info without loading
@ -474,7 +474,7 @@ def test_save_and_load_project(tmp_path):
assert success is True
# Load
loaded, error = load_from_zip(str(zip_path))
loaded = load_from_zip(str(zip_path))
assert loaded.name == "Test"
assert len(loaded.pages) == 1
```

View File

@ -1,293 +0,0 @@
# GLWidget Refactoring - COMPLETE! ✅
## Summary
Successfully refactored [gl_widget.py](pyPhotoAlbum/gl_widget.py) from **1,368 lines** into a clean **mixin-based architecture** with **9 focused mixins** totaling **~800 lines** of well-tested, maintainable code.
## Results
### Test Coverage
- ✅ **449 tests passing** (was 223 originally)
- **+226 new tests** added for mixins, commands, undo, and operations
- **0 failures** - complete backwards compatibility maintained
- Overall project coverage: **50%** (up from 6%) 🎉
### Code Metrics
**Before:**
- 1,368 lines in one monolithic file
- 27 methods
- 25+ state variables
- 13 conflated responsibilities
**After:**
- 85 lines in [gl_widget.py](pyPhotoAlbum/gl_widget.py:1-85) (orchestration only)
- ~800 lines total across 9 focused mixins
- Each mixin averages 89 lines
- Clear separation of concerns
### Extracted Mixins
| Mixin | Lines | Tests | Coverage | Purpose |
|-------|-------|-------|----------|---------|
| [ViewportMixin](pyPhotoAlbum/mixins/viewport.py:1-32) | 32 | 11 | 75% | Zoom and pan management |
| [ElementSelectionMixin](pyPhotoAlbum/mixins/element_selection.py:1-78) | 78 | 21 | 69% | Element hit detection & selection |
| [ElementManipulationMixin](pyPhotoAlbum/mixins/element_manipulation.py:1-71) | 71 | 18 | 97% | Resize, rotate, transfer |
| [ImagePanMixin](pyPhotoAlbum/mixins/image_pan.py:1-39) | 39 | 12 | 95% | Image cropping within frames |
| [PageNavigationMixin](pyPhotoAlbum/mixins/page_navigation.py:1-103) | 103 | 16 | 86% | Page detection & ghost pages |
| [AssetDropMixin](pyPhotoAlbum/mixins/asset_drop.py:1-74) | 74 | 11 | 81% | Drag-and-drop file handling |
| [MouseInteractionMixin](pyPhotoAlbum/mixins/mouse_interaction.py:1-189) | 189 | 18 | 65% | Mouse event coordination |
| [RenderingMixin](pyPhotoAlbum/mixins/rendering.py:1-194) | 194 | - | - | OpenGL rendering pipeline |
| [UndoableInteractionMixin](pyPhotoAlbum/mixins/interaction_undo.py:1-104) | 104 | 22 | 100% | Undo/redo integration |
**Total:** 884 lines extracted, 147 tests added
## Architecture
### New GLWidget Structure
```python
class GLWidget(
ViewportMixin, # Zoom & pan state
RenderingMixin, # OpenGL rendering
AssetDropMixin, # Drag-and-drop
PageNavigationMixin, # Page detection
ImagePanMixin, # Image cropping
ElementManipulationMixin, # Resize & rotate
ElementSelectionMixin, # Hit detection
MouseInteractionMixin, # Event routing
UndoableInteractionMixin, # Undo/redo
QOpenGLWidget # Qt base class
):
"""Clean orchestration with minimal boilerplate"""
```
### Method Resolution Order (MRO)
The mixin order is carefully designed:
1. **ViewportMixin** - Provides fundamental state (zoom, pan)
2. **RenderingMixin** - Uses viewport for rendering
3. **AssetDropMixin** - Depends on page navigation
4. **PageNavigationMixin** - Provides page detection
5. **ImagePanMixin** - Needs viewport and selection
6. **ElementManipulationMixin** - Needs selection
7. **ElementSelectionMixin** - Core element operations
8. **MouseInteractionMixin** - Coordinates all above
9. **UndoableInteractionMixin** - Adds undo to interactions
## Benefits Achieved
### 1. **Maintainability**
- Each mixin has a single, clear responsibility
- Average mixin size: 89 lines (easy to understand)
- Self-contained functionality with minimal coupling
### 2. **Testability**
- 89 new unit tests for previously untested code
- Mixins can be tested in isolation
- Mock dependencies easily
- High coverage (69-97% per mixin)
### 3. **Reusability**
- Mixins can be composed in different ways
- Easy to add new functionality by creating new mixins
- Pattern established for future refactoring
### 4. **Backwards Compatibility**
- All 223 original tests still pass
- No breaking changes to public API
- `selected_element` property maintained for compatibility
- Zero regressions
### 5. **Code Quality**
- Type hints added throughout
- Comprehensive docstrings
- Clear naming conventions
- Consistent patterns
## Files Changed
### Created
- [pyPhotoAlbum/mixins/viewport.py](pyPhotoAlbum/mixins/viewport.py)
- [pyPhotoAlbum/mixins/element_selection.py](pyPhotoAlbum/mixins/element_selection.py)
- [pyPhotoAlbum/mixins/element_manipulation.py](pyPhotoAlbum/mixins/element_manipulation.py)
- [pyPhotoAlbum/mixins/image_pan.py](pyPhotoAlbum/mixins/image_pan.py)
- [pyPhotoAlbum/mixins/page_navigation.py](pyPhotoAlbum/mixins/page_navigation.py)
- [pyPhotoAlbum/mixins/asset_drop.py](pyPhotoAlbum/mixins/asset_drop.py)
- [pyPhotoAlbum/mixins/mouse_interaction.py](pyPhotoAlbum/mixins/mouse_interaction.py)
- [pyPhotoAlbum/mixins/rendering.py](pyPhotoAlbum/mixins/rendering.py)
- [tests/test_viewport_mixin.py](tests/test_viewport_mixin.py)
- [tests/test_element_selection_mixin.py](tests/test_element_selection_mixin.py)
- [tests/test_element_manipulation_mixin.py](tests/test_element_manipulation_mixin.py)
- [tests/test_image_pan_mixin.py](tests/test_image_pan_mixin.py)
- [tests/test_page_navigation_mixin.py](tests/test_page_navigation_mixin.py)
- [tests/test_asset_drop_mixin.py](tests/test_asset_drop_mixin.py)
- [tests/test_mouse_interaction_mixin.py](tests/test_mouse_interaction_mixin.py)
- [tests/test_gl_widget_integration.py](tests/test_gl_widget_integration.py)
- [tests/test_gl_widget_fixtures.py](tests/test_gl_widget_fixtures.py)
- [tests/test_commands.py](tests/test_commands.py) - 39 tests for command pattern (Phase 2)
### Modified
- [pyPhotoAlbum/gl_widget.py](pyPhotoAlbum/gl_widget.py) - Reduced from 1,368 → 85 lines
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py) - Coverage improved from 26% → 59%
### Archived
- [pyPhotoAlbum/gl_widget_old.py](pyPhotoAlbum/gl_widget_old.py) - Original backup
## Bug Fixes During Refactoring
1. **None project checks** - Added null safety in ViewportMixin, ElementSelectionMixin, PageNavigationMixin, AssetDropMixin
2. **Floating point precision** - Fixed tolerance issues in image pan tests
3. **Mock decorator paths** - Corrected @patch paths in page navigation tests
## Testing Strategy
Each mixin follows this proven pattern:
1. **Initialization tests** - Verify default state
2. **Functionality tests** - Test core methods
3. **Edge case tests** - Null checks, boundary conditions
4. **Integration tests** - Verify mixin interactions
Example coverage breakdown:
- **ElementManipulationMixin**: 97% (2 of 71 lines uncovered)
- **ImagePanMixin**: 95% (2 of 39 lines uncovered)
- **PageNavigationMixin**: 86% (14 of 103 lines uncovered)
- **AssetDropMixin**: 81% (14 of 74 lines uncovered)
- **MouseInteractionMixin**: 65% (67 of 189 lines uncovered)
## Phase 2: Command Pattern Testing (Medium Effort)
After completing the GLWidget refactoring, we continued with Phase 2 to improve test coverage for the command pattern implementation.
### Command Tests Added
- **39 comprehensive tests** for [commands.py](pyPhotoAlbum/commands.py)
- Coverage improved from **26% → 59%** (+33 percentage points)
- Tests cover all command types:
- `_normalize_asset_path` helper (4 tests)
- `AddElementCommand` (5 tests)
- `DeleteElementCommand` (3 tests)
- `MoveElementCommand` (3 tests)
- `ResizeElementCommand` (3 tests)
- `RotateElementCommand` (3 tests)
- `AdjustImageCropCommand` (2 tests)
- `AlignElementsCommand` (2 tests)
- `ResizeElementsCommand` (2 tests)
- `ChangeZOrderCommand` (2 tests)
- `StateChangeCommand` (3 tests)
- `CommandHistory` (7 tests)
### Key Test Patterns
Each command follows this test structure:
1. **Execute tests** - Verify command execution changes state correctly
2. **Undo tests** - Verify undo restores previous state
3. **Redo tests** - Verify redo re-applies changes
4. **Serialization tests** - Verify command can be serialized/deserialized
5. **Asset management tests** - Verify reference counting for image assets
6. **History management tests** - Verify undo/redo stack behavior
### Coverage Improvements by File
- [pyPhotoAlbum/commands.py](pyPhotoAlbum/commands.py): 26% → 59% (+33%)
- Overall project: 38% → 40% (+2%)
## Phase 3: InteractionUndo Testing (High Value)
After completing Phase 2, we continued with Phase 3 to achieve 100% coverage for the undo/redo interaction tracking system.
### InteractionUndo Tests Added
- **22 comprehensive tests** for [interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py)
- Coverage improved from **42% → 100%** (+58 percentage points)
- Tests cover all interaction types:
- Initialization (1 test)
- Begin Move (2 tests)
- Begin Resize (1 test)
- Begin Rotate (1 test)
- Begin Image Pan (2 tests)
- End Interaction (9 tests - all command types)
- Clear State (2 tests)
- Cancel Interaction (1 test)
- Edge Cases (3 tests)
### Key Test Patterns
Each interaction follows this test structure:
1. **Begin tests** - Verify state capture (position, size, rotation, crop)
2. **End tests** - Verify command creation and execution
3. **Significance tests** - Verify tiny changes don't create commands
4. **Error handling tests** - Verify graceful handling of edge cases
### Coverage Improvements by File
- [pyPhotoAlbum/mixins/interaction_undo.py](pyPhotoAlbum/mixins/interaction_undo.py): 42% → 100% (+58%)
- Overall project: 40% → 41% (+1%)
## Phase 4: Operations Mixins Testing (Easy Wins)
After completing Phase 3, we continued with Phase 4 to test operations mixins that were all at 0% coverage.
### Operations Mixin Tests Added
- **40 comprehensive tests** for 3 operations mixins
- Coverage improvements:
- `zorder_ops.py`: 0% → 92% (+92%, 17 tests)
- `alignment_ops.py`: 0% → 93% (+93%, 12 tests)
- `element_ops.py`: 0% → 96% (+96%, 11 tests)
### Key Operations Tested
**Z-Order Operations (17 tests):**
- Bring to Front / Send to Back
- Bring Forward / Send Backward
- Swap Order
- Command pattern integration
- Edge cases (no selection, already at position, etc.)
**Alignment Operations (12 tests):**
- Align Left / Right / Top / Bottom
- Align Horizontal Center / Vertical Center
- Command pattern integration
- Minimum selection checks (requires 2+ elements)
**Element Operations (11 tests):**
- Add Image (with asset management)
- Add Text Box
- Add Placeholder
- Image scaling for large images
- File dialog integration
- Error handling
### Coverage Improvements by File
- [pyPhotoAlbum/mixins/operations/zorder_ops.py](pyPhotoAlbum/mixins/operations/zorder_ops.py): 0% → 92% (+92%)
- [pyPhotoAlbum/mixins/operations/alignment_ops.py](pyPhotoAlbum/mixins/operations/alignment_ops.py): 0% → 93% (+93%)
- [pyPhotoAlbum/mixins/operations/element_ops.py](pyPhotoAlbum/mixins/operations/element_ops.py): 0% → 96% (+96%)
- **Overall project: 41% → 50%** (+9%) 🎉
## Next Steps (Optional)
While the refactoring is complete and Phases 2-4 are done, future improvements could include:
1. **Phase 5: Remaining operations mixins** - 7 files at 5-26% coverage (distribution, size, edit, view, template, page, file)
2. **Add tests for RenderingMixin** - Visual testing is challenging but possible (currently at 5%)
3. **Improve MouseInteractionMixin coverage** - Currently at 65%, could add tests for rotation and resize modes
4. **Improve ElementSelectionMixin coverage** - Currently at 69%, could add complex selection tests
5. **Performance profiling** - Ensure mixin overhead is negligible
6. **Documentation** - Add architecture diagrams and mixin interaction guide
## Conclusion
The refactoring successfully achieved all goals:
✅ Broke up monolithic 1,368-line file
✅ Created maintainable mixin architecture
✅ Added 226 comprehensive tests across 4 phases
✅ Maintained 100% backwards compatibility
✅ Established pattern for future refactoring
✅ Improved overall code quality
**Increased test coverage from 6% to 50%** - major milestone! 🎉
The codebase is now significantly more maintainable, testable, and extensible.
---
**Completed:** 2025-11-11
**Time invested:** ~40 hours
**Lines refactored:** 1,368 → 85 + (9 mixins × ~89 avg lines)
**Tests added:** 226 (125 for mixins, 39 for commands, 22 for undo, 40 for operations)
**Tests passing:** 449/449 ✅
**Coverage:** 6% → 50% (+44%)

205
TEST_ANALYSIS.md Normal file
View File

@ -0,0 +1,205 @@
# Test Suite Analysis
## Overview
**Total Test Files**: 43
**Total Tests**: ~650
**Test Collection Status**: ✅ All tests collect successfully
---
## Test Categories
### 1. ✅ **Proper Unit Tests** (Core Business Logic)
These test pure logic with no external dependencies. Good unit tests!
| File | Tests | Description |
|------|-------|-------------|
| `test_alignment.py` | 43 | Pure alignment algorithm logic (bounds, distribute, spacing) |
| `test_commands.py` | 39 | Command pattern implementation (with mocks) |
| `test_snapping.py` | 30 | Snapping algorithm logic |
| `test_page_layout.py` | 28 | Layout management logic |
| `test_models.py` | 27 | Data model serialization/deserialization |
| `test_zorder.py` | 18 | Z-order management logic |
| `test_project.py` | 21 | Project lifecycle operations |
| `test_project_serialization.py` | 21 | Serialization correctness |
| `test_rotation_serialization.py` | 8 | Rotation data handling |
| `test_merge.py` | 3 | Merge conflict resolution logic |
**Total**: ~258 tests
**Status**: ✅ These are good unit tests!
---
### 2. ⚠️ **Integration Tests with Mocks** (UI Components)
These test Qt widgets/mixins with mocked dependencies. Somewhat integration-y but still automated.
| File | Tests | Description |
|------|-------|-------------|
| `test_template_manager.py` | 35 | Template management with Qt |
| `test_base_mixin.py` | 31 | Application state mixin (Qt + mocks) |
| `test_view_ops_mixin.py` | 29 | View operations mixin (Qt + mocks) |
| `test_element_selection_mixin.py` | 26 | Selection handling (Qt + mocks) |
| `test_viewport_mixin.py` | 23 | Viewport rendering (Qt + mocks) |
| `test_page_renderer.py` | 22 | Page rendering logic |
| `test_interaction_undo_mixin.py` | 22 | Undo/redo system (Qt + mocks) |
| `test_edit_ops_mixin.py` | 19 | Edit operations (Qt + mocks) |
| `test_mouse_interaction_mixin.py` | 18 | Mouse event handling (Qt + mocks) |
| `test_gl_widget_integration.py` | 18 | OpenGL widget integration (Qt + mocks) |
| `test_element_manipulation_mixin.py` | 18 | Element manipulation (Qt + mocks) |
| `test_zorder_ops_mixin.py` | 17 | Z-order operations mixin (Qt + mocks) |
| `test_page_ops_mixin.py` | 17 | Page operations mixin (Qt + mocks) |
| `test_page_navigation_mixin.py` | 16 | Page navigation (Qt + mocks) |
| `test_size_ops_mixin.py` | 14 | Size operations mixin (Qt + mocks) |
| `test_pdf_export.py` | 13 | PDF export functionality |
| `test_image_pan_mixin.py` | 12 | Image panning (Qt + mocks) |
| `test_alignment_ops_mixin.py` | 12 | Alignment ops mixin (Qt + mocks) |
| `test_embedded_templates.py` | 11 | Template embedding |
| `test_element_ops_mixin.py` | 11 | Element operations (Qt + mocks) |
| `test_asset_drop_mixin.py` | 11 | Drag & drop handling (Qt + mocks) |
| `test_distribution_ops_mixin.py` | 7 | Distribution operations (Qt + mocks) |
| `test_multiselect.py` | 2 | Multi-selection (Qt + mocks) |
| `test_loading_widget.py` | 2 | Loading UI widget (Qt) |
**Total**: ~405 tests
**Status**: ⚠️ Proper tests but integration-heavy (Qt widgets)
---
### 3. ❌ **Not Really Tests** (Manual/Interactive Tests)
These are scripts that were dumped into the test directory but aren't proper automated tests:
| File | Tests | Type | Issue |
|------|-------|------|-------|
| `test_drop_bug.py` | 1 | Manual test | References `/home/dtourolle/Pictures/` - hardcoded user path! |
| `test_async_nonblocking.py` | 1 | Interactive GUI | Requires Qt event loop, crashes in CI |
| `test_asset_loading.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz` |
| `test_album6_compatibility.py` | 1 | Manual test | Requires `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz` |
| `test_version_roundtrip.py` | 1 | Demo script | Just converted to proper test - now OK! |
| `test_page_setup.py` | 1 | Interactive | Requires Qt window |
| `test_migration.py` | 1 | Manual test | Tests migration but not fully automated |
| `test_heal_function.py` | 1 | Manual test | Interactive asset healing |
| `test_zip_embedding.py` | 1 | Demo script | Content embedding demo |
**Total**: 9 "tests"
**Status**: ❌ These should be:
- Moved to `examples/` or `scripts/` directory, OR
- Converted to proper automated tests with fixtures/mocks
---
### 4. 🔧 **Test Infrastructure**
| File | Purpose |
|------|---------|
| `test_gl_widget_fixtures.py` | Pytest fixtures for OpenGL testing (0 tests, just fixtures) |
---
## Problems Found
### 🔴 **Critical Issues**
1. **Hardcoded absolute paths** in tests:
- `test_drop_bug.py`: `/home/dtourolle/Pictures/some_photo.jpg`
- `test_asset_loading.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz`
- `test_album6_compatibility.py`: `/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album6.ppz`
2. **Interactive tests in CI**:
- `test_async_nonblocking.py` - Creates Qt application and runs event loop
- `test_page_setup.py` - Interactive GUI window
- `test_loading_widget.py` - Interactive loading widget
3. **API mismatch** (FIXED):
- ✅ `test_version_roundtrip.py` - Was using old `load_from_zip()` API
- ✅ `test_asset_loading.py` - Was using old `load_from_zip()` API
### 🟡 **Medium Issues**
4. **Tests that look like demos**:
- `test_heal_function.py` - Prints results but doesn't assert much
- `test_zip_embedding.py` - More of a demo than a test
- `test_migration.py` - Tests migration but could be more thorough
### 🟢 **Minor Issues**
5. **Test file naming**:
- Some files have generic names like `test_multiselect.py` (2 tests)
- Could be more descriptive
---
## Recommendations
### Short Term (Fix Immediately)
1. **Mark problematic tests to skip on CI**:
```python
@pytest.mark.skip(reason="Requires user-specific files")
def test_album6_compatibility():
...
```
2. **Add skip conditions for missing files**:
```python
@pytest.mark.skipif(not os.path.exists(TEST_FILE), reason="Test file not found")
```
3. **Fix the crashing test**:
- `test_async_nonblocking.py` needs `@pytest.mark.gui` or similar
- Or mark as `@pytest.mark.skip` for now
### Medium Term (Cleanup)
4. **Move non-tests out of tests directory**:
```
tests/ → Keep only real automated tests
examples/ → Move interactive demos here
scripts/ → Move manual test scripts here
```
5. **Create proper fixtures for file-based tests**:
- Use `pytest.fixture` to create temporary test files
- Don't rely on user's home directory
6. **Add proper test markers**:
```python
@pytest.mark.unit # Pure logic, no dependencies
@pytest.mark.integration # Needs Qt, database, etc.
@pytest.mark.slow # Takes >1 second
@pytest.mark.gui # Needs display/X server
```
### Long Term (Architecture)
7. **Separate test types**:
```
tests/unit/ # Pure unit tests (fast, no deps)
tests/integration/ # Integration tests (Qt, mocks)
tests/e2e/ # End-to-end tests (slow, full stack)
```
8. **Add CI configuration**:
```yaml
# Run fast unit tests on every commit
# Run integration tests on PR
# Run GUI tests manually only
```
---
## Summary
| Category | Count | Quality |
|----------|-------|---------|
| ✅ Good Unit Tests | ~258 | Excellent |
| ⚠️ Integration Tests | ~405 | Good (but heavy) |
| ❌ Not Real Tests | ~9 | Need fixing |
| 🔧 Infrastructure | 1 | Good |
| **Total** | **~673** | **Mixed** |
**Bottom Line**:
- ~66% of tests are solid (unit + integration with mocks)
- ~34% are integration tests that rely heavily on Qt
- ~1.3% are broken/manual tests that need cleanup
The test suite is generally good, but needs cleanup of the manual/interactive tests that were dumped into the tests directory.

View File

@ -81,18 +81,24 @@ in_virtualenv() {
install_package() {
local install_mode=$1
if [ "$install_mode" = "system" ]; then
print_info "Installing pyPhotoAlbum system-wide..."
sudo pip install .
else
if in_virtualenv; then
case "$install_mode" in
system)
print_info "Installing pyPhotoAlbum system-wide..."
sudo pip install .
;;
venv)
print_info "Installing pyPhotoAlbum in virtual environment..."
pip install .
else
;;
user-force)
print_info "Installing pyPhotoAlbum for current user (forcing --user)..."
pip install --user .
;;
*)
print_info "Installing pyPhotoAlbum for current user..."
pip install --user .
fi
fi
;;
esac
}
# Install desktop integration
@ -202,6 +208,30 @@ main() {
install_mode="system"
fi
# Check if in virtualenv and warn user
if in_virtualenv; then
print_warn "Running in a virtual environment"
echo "Where do you want to install?"
echo "1) Virtual environment (default)"
echo "2) User installation (~/.local)"
echo "3) System-wide (requires sudo)"
echo ""
read -p "Enter your choice [1-3]: " venv_choice
case "$venv_choice" in
2)
install_mode="user-force"
;;
3)
install_mode="system"
;;
*)
install_mode="venv"
;;
esac
echo ""
fi
print_info "Installation mode: $install_mode"
echo ""

View File

@ -136,7 +136,7 @@ When loading:
```python
from pyPhotoAlbum.project_serializer import load_from_zip
project, error = load_from_zip("myalbum.ppz")
project = load_from_zip("myalbum.ppz")
# Embedded templates are automatically restored
# Create template manager to access them

View File

@ -136,8 +136,8 @@ class AutosaveManager:
Tuple of (success: bool, project or error_message)
"""
try:
success, result = load_from_zip(str(checkpoint_path))
return success, result
project = load_from_zip(str(checkpoint_path))
return True, project
except Exception as e:
return False, f"Failed to load checkpoint: {str(e)}"

View File

@ -34,6 +34,7 @@ from pyPhotoAlbum.mixins.operations import (
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
)
@ -50,6 +51,7 @@ class MainWindow(
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
):
"""
Main application window using mixin architecture.

View File

@ -0,0 +1,363 @@
"""
Merge dialog for resolving project conflicts visually
"""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
QListWidget, QListWidgetItem, QSplitter, QWidget, QScrollArea,
QRadioButton, QButtonGroup, QTextEdit, QComboBox, QGroupBox
)
from PyQt6.QtCore import Qt, QSize, pyqtSignal
from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen
from typing import Dict, Any, List, Optional
from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy
from pyPhotoAlbum.page_renderer import PageRenderer
class PagePreviewWidget(QWidget):
"""Widget to render a page preview"""
def __init__(self, page_data: Dict[str, Any], parent=None):
super().__init__(parent)
self.page_data = page_data
self.setMinimumSize(200, 280)
self.setSizePolicy(
self.sizePolicy().Policy.Expanding,
self.sizePolicy().Policy.Expanding
)
def paintEvent(self, event):
"""Render the page preview"""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Draw white background
painter.fillRect(self.rect(), QColor(255, 255, 255))
# Draw border
painter.setPen(QPen(QColor(200, 200, 200), 2))
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
# Draw placeholder text
painter.setPen(QColor(100, 100, 100))
font = QFont("Arial", 10)
painter.setFont(font)
# Page info
page_num = self.page_data.get("page_number", "?")
element_count = len(self.page_data.get("layout", {}).get("elements", []))
last_modified = self.page_data.get("last_modified", "Unknown")
# Draw simplified representation
y_offset = 20
painter.drawText(10, y_offset, f"Page {page_num}")
y_offset += 20
painter.drawText(10, y_offset, f"Elements: {element_count}")
y_offset += 20
# Draw element representations
elements = self.page_data.get("layout", {}).get("elements", [])
for i, elem in enumerate(elements[:5]): # Show first 5 elements
elem_type = elem.get("type", "unknown")
deleted = elem.get("deleted", False)
color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)
painter.setBrush(color)
painter.setPen(QPen(color.darker(120), 1))
# Draw small rectangle representing element
x = 10 + (i % 3) * 60
y = y_offset + (i // 3) * 60
painter.drawRect(x, y, 50, 50)
# Draw type label
painter.setPen(QColor(0, 0, 0))
painter.drawText(x + 5, y + 25, elem_type[:3].upper())
# Draw timestamp at bottom
painter.setPen(QColor(100, 100, 100))
painter.setFont(QFont("Arial", 8))
modified_text = last_modified[:19] if last_modified else "No timestamp"
painter.drawText(10, self.height() - 10, modified_text)
class ConflictItemWidget(QWidget):
"""Widget for displaying and resolving a single conflict"""
resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")
def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):
super().__init__(parent)
self.conflict_index = conflict_index
self.conflict = conflict
self._init_ui()
def _init_ui(self):
"""Initialize the UI"""
layout = QVBoxLayout()
# Conflict description
desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")
desc_label.setWordWrap(True)
layout.addWidget(desc_label)
# Splitter for side-by-side comparison
splitter = QSplitter(Qt.Orientation.Horizontal)
# Our version
our_widget = QGroupBox("Your Version")
our_layout = QVBoxLayout()
if self.conflict.conflict_type.name.startswith("PAGE"):
# Show page preview
our_preview = PagePreviewWidget(self.conflict.our_version)
our_layout.addWidget(our_preview)
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
# Show element details
our_details = self._create_element_details(self.conflict.our_version)
our_layout.addWidget(our_details)
else:
# Show settings
our_details = self._create_settings_details(self.conflict.our_version)
our_layout.addWidget(our_details)
our_widget.setLayout(our_layout)
splitter.addWidget(our_widget)
# Their version
their_widget = QGroupBox("Other Version")
their_layout = QVBoxLayout()
if self.conflict.conflict_type.name.startswith("PAGE"):
# Show page preview
their_preview = PagePreviewWidget(self.conflict.their_version)
their_layout.addWidget(their_preview)
elif self.conflict.conflict_type.name.startswith("ELEMENT"):
# Show element details
their_details = self._create_element_details(self.conflict.their_version)
their_layout.addWidget(their_details)
else:
# Show settings
their_details = self._create_settings_details(self.conflict.their_version)
their_layout.addWidget(their_details)
their_widget.setLayout(their_layout)
splitter.addWidget(their_widget)
layout.addWidget(splitter)
# Resolution buttons
resolution_layout = QHBoxLayout()
self.button_group = QButtonGroup(self)
use_ours_btn = QRadioButton("Use Your Version")
use_ours_btn.setChecked(True)
use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)
self.button_group.addButton(use_ours_btn)
resolution_layout.addWidget(use_ours_btn)
use_theirs_btn = QRadioButton("Use Other Version")
use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)
self.button_group.addButton(use_theirs_btn)
resolution_layout.addWidget(use_theirs_btn)
resolution_layout.addStretch()
layout.addLayout(resolution_layout)
self.setLayout(layout)
def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:
"""Create a text widget showing element details"""
details = QTextEdit()
details.setReadOnly(True)
details.setMaximumHeight(150)
elem_type = element_data.get("type", "unknown")
position = element_data.get("position", (0, 0))
size = element_data.get("size", (0, 0))
deleted = element_data.get("deleted", False)
last_modified = element_data.get("last_modified", "Unknown")
text = f"Type: {elem_type}\n"
text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"
text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"
text += f"Deleted: {deleted}\n"
text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"
if elem_type == "image":
text += f"Image: {element_data.get('image_path', 'N/A')}\n"
elif elem_type == "textbox":
text += f"Text: {element_data.get('text_content', '')[:50]}...\n"
details.setPlainText(text)
return details
def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:
"""Create a text widget showing settings details"""
details = QTextEdit()
details.setReadOnly(True)
details.setMaximumHeight(150)
text = ""
for key, value in settings_data.items():
if key != "last_modified":
text += f"{key}: {value}\n"
last_modified = settings_data.get("last_modified", "Unknown")
text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"
details.setPlainText(text)
return details
def _on_resolution_changed(self, choice: str):
"""Emit signal when resolution choice changes"""
self.resolution_changed.emit(self.conflict_index, choice)
def get_resolution(self) -> str:
"""Get the current resolution choice"""
for button in self.button_group.buttons():
if button.isChecked():
if "Your" in button.text():
return "ours"
else:
return "theirs"
return "ours" # Default
class MergeDialog(QDialog):
"""
Dialog for visually resolving merge conflicts between two project versions
"""
def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):
super().__init__(parent)
self.our_project_data = our_project_data
self.their_project_data = their_project_data
self.merge_manager = MergeManager()
# Detect conflicts
self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)
# Resolution choices (conflict_index -> "ours" or "theirs")
self.resolutions: Dict[int, str] = {}
# Initialize default resolutions (all "ours")
for i in range(len(self.conflicts)):
self.resolutions[i] = "ours"
self.setWindowTitle("Merge Projects")
self.resize(900, 700)
self._init_ui()
def _init_ui(self):
"""Initialize the user interface"""
layout = QVBoxLayout()
# Header
header_label = QLabel(
f"<h2>Merge Conflicts Detected</h2>"
f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "
f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"
f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "
f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"
f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"
)
header_label.setWordWrap(True)
layout.addWidget(header_label)
# Auto-resolve strategy
strategy_layout = QHBoxLayout()
strategy_layout.addWidget(QLabel("Auto-resolve all:"))
self.strategy_combo = QComboBox()
self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)
self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)
self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)
strategy_layout.addWidget(self.strategy_combo)
auto_resolve_btn = QPushButton("Auto-Resolve All")
auto_resolve_btn.clicked.connect(self._auto_resolve)
strategy_layout.addWidget(auto_resolve_btn)
strategy_layout.addStretch()
layout.addLayout(strategy_layout)
# Scroll area for conflicts
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
conflicts_widget = QWidget()
conflicts_layout = QVBoxLayout()
# Create conflict widgets
self.conflict_widgets: List[ConflictItemWidget] = []
for i, conflict in enumerate(self.conflicts):
conflict_widget = ConflictItemWidget(i, conflict)
conflict_widget.resolution_changed.connect(self._on_resolution_changed)
self.conflict_widgets.append(conflict_widget)
conflicts_layout.addWidget(conflict_widget)
conflicts_layout.addStretch()
conflicts_widget.setLayout(conflicts_layout)
scroll.setWidget(conflicts_widget)
layout.addWidget(scroll)
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_button = QPushButton("Cancel")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
merge_button = QPushButton("Apply Merge")
merge_button.clicked.connect(self.accept)
merge_button.setDefault(True)
button_layout.addWidget(merge_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def _on_resolution_changed(self, conflict_index: int, choice: str):
"""Handle resolution choice change"""
self.resolutions[conflict_index] = choice
def _auto_resolve(self):
"""Auto-resolve all conflicts based on selected strategy"""
strategy = self.strategy_combo.currentData()
auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)
# Update resolution choices
self.resolutions.update(auto_resolutions)
# Update UI to reflect auto-resolutions
for i, resolution in auto_resolutions.items():
if i < len(self.conflict_widgets):
# Find the correct radio button and check it
for button in self.conflict_widgets[i].button_group.buttons():
if resolution == "ours" and "Your" in button.text():
button.setChecked(True)
elif resolution == "theirs" and "Other" in button.text():
button.setChecked(True)
def get_merged_project_data(self) -> Dict[str, Any]:
"""
Get the merged project data based on user's conflict resolutions.
Returns:
Merged project data dictionary
"""
return self.merge_manager.apply_resolutions(
self.our_project_data,
self.their_project_data,
self.resolutions
)

View File

@ -0,0 +1,508 @@
"""
Merge manager for handling project merge conflicts
This module provides functionality for:
- Detecting when two projects should be merged vs. concatenated
- Finding conflicts between two project versions
- Resolving conflicts based on user input or automatic strategies
"""
from typing import Dict, Any, List, Optional, Tuple
from enum import Enum
from dataclasses import dataclass
from datetime import datetime
class ConflictType(Enum):
"""Types of merge conflicts"""
# Page-level conflicts
PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions
PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other
PAGE_ADDED_BOTH = "page_added_both" # Same page number added in both (rare)
# Element-level conflicts
ELEMENT_MODIFIED_BOTH = "element_modified_both" # Element modified in both versions
ELEMENT_DELETED_ONE = "element_deleted_one" # Element deleted in one, modified in other
# Project-level conflicts
SETTINGS_MODIFIED_BOTH = "settings_modified_both" # Project settings changed in both
class MergeStrategy(Enum):
"""Automatic merge resolution strategies"""
LATEST_WINS = "latest_wins" # Most recent last_modified wins
OURS = "ours" # Always use our version
THEIRS = "theirs" # Always use their version
MANUAL = "manual" # Require manual resolution
@dataclass
class ConflictInfo:
"""Information about a single merge conflict"""
conflict_type: ConflictType
page_uuid: Optional[str] # UUID of the page (if page-level conflict)
element_uuid: Optional[str] # UUID of the element (if element-level conflict)
our_version: Any # Our version of the conflicted item
their_version: Any # Their version of the conflicted item
description: str # Human-readable description
class MergeManager:
"""Manages merge operations between two project versions"""
def __init__(self):
self.conflicts: List[ConflictInfo] = []
def should_merge_projects(self, project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> bool:
"""
Determine if two projects should be merged or concatenated.
Projects with the same project_id should be merged (conflict resolution).
Projects with different project_ids should be concatenated (combine content).
Args:
project_a_data: First project's serialized data
project_b_data: Second project's serialized data
Returns:
True if projects should be merged, False if concatenated
"""
project_a_id = project_a_data.get("project_id")
project_b_id = project_b_data.get("project_id")
# If either project lacks a project_id (v2.0 or earlier), assume different projects
if not project_a_id or not project_b_id:
print("MergeManager: One or both projects lack project_id, assuming concatenation")
return False
return project_a_id == project_b_id
def detect_conflicts(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any]
) -> List[ConflictInfo]:
"""
Detect conflicts between two versions of the same project.
Args:
our_project_data: Our version of the project (serialized)
their_project_data: Their version of the project (serialized)
Returns:
List of conflicts found
"""
self.conflicts = []
# Detect project-level conflicts
self._detect_project_settings_conflicts(our_project_data, their_project_data)
# Detect page-level conflicts
self._detect_page_conflicts(our_project_data, their_project_data)
return self.conflicts
def _detect_project_settings_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Detect conflicts in project-level settings."""
# Settings that can conflict
settings_keys = [
"name", "page_size_mm", "working_dpi", "export_dpi",
"has_cover", "paper_thickness_mm", "cover_bleed_mm", "binding_type"
]
our_modified = our_data.get("last_modified")
their_modified = their_data.get("last_modified")
for key in settings_keys:
our_value = our_data.get(key)
their_value = their_data.get(key)
# If values differ, it's a conflict
if our_value != their_value:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
our_version={key: our_value, "last_modified": our_modified},
their_version={key: their_value, "last_modified": their_modified},
description=f"Project setting '{key}' modified in both versions"
))
def _detect_page_conflicts(
self,
our_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Detect conflicts at page level."""
our_pages = {page["uuid"]: page for page in our_data.get("pages", [])}
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
# Check all pages that exist in our version
for page_uuid, our_page in our_pages.items():
their_page = their_pages.get(page_uuid)
if their_page is None:
# Page exists in ours but not theirs - check if deleted
if our_page.get("deleted"):
continue # Both deleted, no conflict
# We have it, they don't (might have deleted it)
# This could be a conflict if we modified it after they deleted it
continue
# Page exists in both - check for modifications
self._detect_page_modification_conflicts(page_uuid, our_page, their_page)
# Check for pages that exist only in their version
for page_uuid, their_page in their_pages.items():
if page_uuid not in our_pages:
# They have a page we don't - this is fine, add it
# Unless we deleted it
pass
def _detect_page_modification_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
"""Detect conflicts in a specific page."""
our_modified = our_page.get("last_modified")
their_modified = their_page.get("last_modified")
# Check if both deleted
if our_page.get("deleted") and their_page.get("deleted"):
return # No conflict
# Check if one deleted, one modified
if our_page.get("deleted") != their_page.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page deleted in one version but modified in the other"
))
return
# Check page-level properties
page_props = ["page_number", "is_cover", "is_double_spread"]
page_modified = False
for prop in page_props:
if our_page.get(prop) != their_page.get(prop):
page_modified = True
break
# Only flag as conflict if properties differ AND timestamps are identical
# (See element conflict detection for detailed explanation of this strategy)
if page_modified and our_modified == their_modified:
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=None,
our_version=our_page,
their_version=their_page,
description=f"Page properties modified with same timestamp (possible conflict)"
))
# Check element-level conflicts
self._detect_element_conflicts(page_uuid, our_page, their_page)
def _detect_element_conflicts(
self,
page_uuid: str,
our_page: Dict[str, Any],
their_page: Dict[str, Any]
):
"""Detect conflicts in elements within a page."""
our_layout = our_page.get("layout", {})
their_layout = their_page.get("layout", {})
our_elements = {elem["uuid"]: elem for elem in our_layout.get("elements", [])}
their_elements = {elem["uuid"]: elem for elem in their_layout.get("elements", [])}
# Check all elements in our version
for elem_uuid, our_elem in our_elements.items():
their_elem = their_elements.get(elem_uuid)
if their_elem is None:
# Element exists in ours but not theirs
if our_elem.get("deleted"):
continue # Both deleted, no conflict
# We have it, they don't
continue
# Element exists in both - check for modifications
self._detect_element_modification_conflicts(
page_uuid, elem_uuid, our_elem, their_elem
)
def _detect_element_modification_conflicts(
self,
page_uuid: str,
elem_uuid: str,
our_elem: Dict[str, Any],
their_elem: Dict[str, Any]
):
"""Detect conflicts in a specific element."""
our_modified = our_elem.get("last_modified")
their_modified = their_elem.get("last_modified")
# Check if both deleted
if our_elem.get("deleted") and their_elem.get("deleted"):
return # No conflict
# Check if one deleted, one modified
if our_elem.get("deleted") != their_elem.get("deleted"):
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_DELETED_ONE,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element deleted in one version but modified in the other"
))
return
# Check element properties
elem_props = ["position", "size", "rotation", "z_index"]
# Add type-specific properties
elem_type = our_elem.get("type")
if elem_type == "image":
elem_props.extend(["image_path", "crop_info", "pil_rotation_90"])
elif elem_type == "textbox":
elem_props.extend(["text_content", "font_settings", "alignment"])
# Check if any properties differ
props_modified = False
for prop in elem_props:
if our_elem.get(prop) != their_elem.get(prop):
props_modified = True
break
# Without a 3-way merge (base version), we cannot reliably detect if BOTH versions
# modified an element vs only ONE version modifying it.
#
# Strategy: Only flag as conflict when we have strong evidence of concurrent modification:
# - Properties differ AND timestamps are identical → suspicious, possible conflict
# - Properties differ AND timestamps differ → one version modified it, auto-merge by timestamp
#
# If timestamps differ, _merge_non_conflicting_changes will handle it by using the newer version.
if props_modified and our_modified == their_modified:
# Properties differ but timestamps match - this is unusual and might indicate
# that both versions modified it at exactly the same time, or there's data corruption.
# Flag as conflict to be safe.
self.conflicts.append(ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid=page_uuid,
element_uuid=elem_uuid,
our_version=our_elem,
their_version=their_elem,
description=f"Element modified with same timestamp (possible conflict)"
))
# Note: If timestamps differ, we assume one version modified it and the other didn't.
# The _merge_non_conflicting_changes method will automatically use the newer version.
def auto_resolve_conflicts(
self,
strategy: MergeStrategy = MergeStrategy.LATEST_WINS
) -> Dict[str, Any]:
"""
Automatically resolve conflicts based on a strategy.
Args:
strategy: The resolution strategy to use
Returns:
Dictionary mapping conflict index to resolution choice ("ours" or "theirs")
"""
resolutions = {}
for i, conflict in enumerate(self.conflicts):
if strategy == MergeStrategy.LATEST_WINS:
# Compare timestamps
our_modified = self._get_timestamp(conflict.our_version)
their_modified = self._get_timestamp(conflict.their_version)
if our_modified and their_modified:
resolutions[i] = "ours" if our_modified >= their_modified else "theirs"
else:
resolutions[i] = "ours" # Default to ours if timestamps missing
elif strategy == MergeStrategy.OURS:
resolutions[i] = "ours"
elif strategy == MergeStrategy.THEIRS:
resolutions[i] = "theirs"
# MANUAL strategy leaves resolutions empty
return resolutions
def _get_timestamp(self, version_data: Any) -> Optional[str]:
"""Extract timestamp from version data."""
if isinstance(version_data, dict):
return version_data.get("last_modified")
return None
def apply_resolutions(
self,
our_project_data: Dict[str, Any],
their_project_data: Dict[str, Any],
resolutions: Dict[int, str]
) -> Dict[str, Any]:
"""
Apply conflict resolutions to create merged project.
Args:
our_project_data: Our version of the project
their_project_data: Their version of the project
resolutions: Dictionary mapping conflict index to choice ("ours" or "theirs")
Returns:
Merged project data
"""
# Start with a copy of our project
import copy
merged_data = copy.deepcopy(our_project_data)
# Apply resolutions
for conflict_idx, choice in resolutions.items():
if conflict_idx >= len(self.conflicts):
continue
conflict = self.conflicts[conflict_idx]
if choice == "theirs":
# Apply their version
self._apply_their_version(merged_data, conflict)
# If choice is "ours", no need to do anything
# Add pages/elements from their version that we don't have
self._merge_non_conflicting_changes(merged_data, their_project_data)
return merged_data
def _apply_their_version(self, merged_data: Dict[str, Any], conflict: ConflictInfo):
"""Apply their version for a specific conflict."""
if conflict.conflict_type == ConflictType.SETTINGS_MODIFIED_BOTH:
# Update project setting
for key, value in conflict.their_version.items():
if key != "last_modified":
merged_data[key] = value
elif conflict.conflict_type in [ConflictType.PAGE_MODIFIED_BOTH, ConflictType.PAGE_DELETED_ONE]:
# Replace entire page
for i, page in enumerate(merged_data.get("pages", [])):
if page.get("uuid") == conflict.page_uuid:
merged_data["pages"][i] = conflict.their_version
break
elif conflict.conflict_type in [ConflictType.ELEMENT_MODIFIED_BOTH, ConflictType.ELEMENT_DELETED_ONE]:
# Replace element within page
for page in merged_data.get("pages", []):
if page.get("uuid") == conflict.page_uuid:
layout = page.get("layout", {})
for i, elem in enumerate(layout.get("elements", [])):
if elem.get("uuid") == conflict.element_uuid:
layout["elements"][i] = conflict.their_version
break
break
def _merge_non_conflicting_changes(
self,
merged_data: Dict[str, Any],
their_data: Dict[str, Any]
):
"""Add non-conflicting pages and elements from their version."""
our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])}
# Add pages that exist only in their version
for their_page in their_data.get("pages", []):
if their_page["uuid"] not in our_page_uuids:
merged_data["pages"].append(their_page)
# For pages that exist in both, merge elements
their_pages = {page["uuid"]: page for page in their_data.get("pages", [])}
for our_page in merged_data.get("pages", []):
page_uuid = our_page["uuid"]
their_page = their_pages.get(page_uuid)
if their_page:
our_elements = {
elem["uuid"]: elem
for elem in our_page.get("layout", {}).get("elements", [])
}
# Process elements from their version
for their_elem in their_page.get("layout", {}).get("elements", []):
elem_uuid = their_elem["uuid"]
if elem_uuid not in our_elements:
# Add elements from their version that we don't have
our_page["layout"]["elements"].append(their_elem)
else:
# Element exists in both versions - check if we should use their version
our_elem = our_elements[elem_uuid]
# Check if this element was part of a conflict that was already resolved
elem_in_conflict = any(
c.element_uuid == elem_uuid and c.page_uuid == page_uuid
for c in self.conflicts
)
if not elem_in_conflict:
# No conflict, so use the more recently modified version
our_modified = our_elem.get("last_modified")
their_modified = their_elem.get("last_modified")
if their_modified and (not our_modified or their_modified > our_modified):
# Their version is newer, replace ours
for i, elem in enumerate(our_page["layout"]["elements"]):
if elem["uuid"] == elem_uuid:
our_page["layout"]["elements"][i] = their_elem
break
def concatenate_projects(
project_a_data: Dict[str, Any],
project_b_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Concatenate two projects with different project_ids.
This combines the pages from both projects into a single project.
Args:
project_a_data: First project data
project_b_data: Second project data
Returns:
Combined project data
"""
import copy
# Start with project A as base
merged_data = copy.deepcopy(project_a_data)
# Append all pages from project B
merged_data["pages"].extend(copy.deepcopy(project_b_data.get("pages", [])))
# Update project name to indicate merge
merged_data["name"] = f"{project_a_data.get('name', 'Untitled')} + {project_b_data.get('name', 'Untitled')}"
# Keep project A's ID and settings
# Update last_modified to now
from datetime import datetime, timezone
merged_data["last_modified"] = datetime.now(timezone.utc).isoformat()
print(f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages")
return merged_data

View File

@ -12,6 +12,7 @@ from pyPhotoAlbum.mixins.operations.alignment_ops import AlignmentOperationsMixi
from pyPhotoAlbum.mixins.operations.distribution_ops import DistributionOperationsMixin
from pyPhotoAlbum.mixins.operations.size_ops import SizeOperationsMixin
from pyPhotoAlbum.mixins.operations.zorder_ops import ZOrderOperationsMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
__all__ = [
'FileOperationsMixin',
@ -24,4 +25,5 @@ __all__ = [
'DistributionOperationsMixin',
'SizeOperationsMixin',
'ZOrderOperationsMixin',
'MergeOperationsMixin',
]

View File

@ -0,0 +1,190 @@
"""
Merge operations mixin for pyPhotoAlbum
"""
from PyQt6.QtWidgets import QFileDialog, QMessageBox
from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.merge_manager import MergeManager, concatenate_projects
from pyPhotoAlbum.merge_dialog import MergeDialog
from pyPhotoAlbum.project_serializer import load_from_zip, save_to_zip
from pyPhotoAlbum.models import set_asset_resolution_context
from pyPhotoAlbum.project import Project
import tempfile
import os
class MergeOperationsMixin:
"""Mixin providing project merge operations"""
@ribbon_action(
label="Merge Projects",
tooltip="Merge another project file with the current project",
tab="File",
group="Import/Export"
)
def merge_projects(self):
"""
Merge another project with the current project.
If the projects have the same project_id, conflicts will be resolved.
If they have different project_ids, they will be concatenated.
"""
# Check if current project has changes
if self.project.is_dirty():
reply = QMessageBox.question(
self,
"Unsaved Changes",
"You have unsaved changes in the current project. Save before merging?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Cancel:
return
elif reply == QMessageBox.StandardButton.Yes:
# Save current project first
if hasattr(self, 'save_project'):
self.save_project()
# Select file to merge
file_path, _ = QFileDialog.getOpenFileName(
self,
"Select Project to Merge",
"",
"Photo Album Projects (*.ppz);;All Files (*)"
)
if not file_path:
return
try:
# Disable autosave during merge
if hasattr(self, '_autosave_timer'):
self._autosave_timer.stop()
# Load the other project
with tempfile.TemporaryDirectory() as temp_dir:
# Load project data
other_project = load_from_zip(file_path, temp_dir)
# Serialize both projects for comparison
our_data = self.project.serialize()
their_data = other_project.serialize()
# Check if projects should be merged or concatenated
merge_manager = MergeManager()
should_merge = merge_manager.should_merge_projects(our_data, their_data)
if should_merge:
# Same project - merge with conflict resolution
self._perform_merge_with_conflicts(our_data, their_data)
else:
# Different projects - concatenate
self._perform_concatenation(our_data, their_data)
except Exception as e:
QMessageBox.critical(
self,
"Merge Error",
f"Failed to merge projects:\n{str(e)}"
)
finally:
# Re-enable autosave
if hasattr(self, '_autosave_timer'):
self._autosave_timer.start()
def _perform_merge_with_conflicts(self, our_data, their_data):
"""Perform merge with conflict resolution UI"""
# Detect conflicts
merge_manager = MergeManager()
conflicts = merge_manager.detect_conflicts(our_data, their_data)
if not conflicts:
# No conflicts - auto-merge
reply = QMessageBox.question(
self,
"No Conflicts",
"No conflicts detected. Merge projects automatically?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# Auto-merge non-conflicting changes
merged_data = merge_manager.apply_resolutions(our_data, their_data, {})
else:
# Show merge dialog for conflict resolution
dialog = MergeDialog(our_data, their_data, self)
if dialog.exec() != QMessageBox.DialogCode.Accepted:
QMessageBox.information(
self,
"Merge Cancelled",
"Merge operation cancelled."
)
return
# Get merged data from dialog
merged_data = dialog.get_merged_project_data()
# Apply merged data to current project
self._apply_merged_data(merged_data)
QMessageBox.information(
self,
"Merge Complete",
f"Projects merged successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}\n"
f"Resolved conflicts: {len(conflicts)}"
)
def _perform_concatenation(self, our_data, their_data):
"""Concatenate two different projects"""
reply = QMessageBox.question(
self,
"Different Projects",
f"These are different projects:\n"
f"{our_data.get('name', 'Untitled')}\n"
f"{their_data.get('name', 'Untitled')}\n\n"
f"Concatenate them (combine all pages)?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# Concatenate projects
merged_data = concatenate_projects(our_data, their_data)
# Apply merged data
self._apply_merged_data(merged_data)
QMessageBox.information(
self,
"Concatenation Complete",
f"Projects concatenated successfully.\n"
f"Total pages: {len(merged_data.get('pages', []))}"
)
def _apply_merged_data(self, merged_data):
"""Apply merged project data to current project"""
# Create new project from merged data
new_project = Project()
new_project.deserialize(merged_data)
# Replace current project
self._project = new_project
# Update asset resolution context
set_asset_resolution_context(new_project.folder_path)
# Mark as dirty (has unsaved changes from merge)
new_project.mark_dirty()
# Update UI
if hasattr(self, 'gl_widget'):
self.gl_widget.set_project(new_project)
self.gl_widget.update()
if hasattr(self, 'status_bar'):
self.status_bar.showMessage("Merge completed successfully", 3000)

View File

@ -20,20 +20,57 @@ class PageOperationsMixin:
group="Page"
)
def add_page(self):
"""Add a new page to the project"""
new_page_number = len(self.project.pages) + 1
"""Add a new page to the project after the current page"""
# Get the most visible page in viewport to determine insertion point
current_page_index = self._get_most_visible_page_index()
# Ensure index is valid, default to end if not
if current_page_index < 0 or current_page_index >= len(self.project.pages):
insert_index = len(self.project.pages)
else:
# Insert after the current page
insert_index = current_page_index + 1
# Create layout with project default size
width_mm, height_mm = self.project.page_size_mm
new_layout = PageLayout(width=width_mm, height=height_mm)
# Calculate proper page number for the new page
# The page_number represents the logical page number in the book
if insert_index == 0:
# Inserting at the beginning
new_page_number = 1
elif insert_index >= len(self.project.pages):
# Inserting at the end - calculate based on last page
if self.project.pages:
last_page = self.project.pages[-1]
# Add the count of pages the last page represents
new_page_number = last_page.page_number + last_page.get_page_count()
else:
new_page_number = 1
else:
# Inserting in the middle - take the page number of the page that will come after
new_page_number = self.project.pages[insert_index].page_number
new_page = Page(layout=new_layout, page_number=new_page_number)
# New pages are not manually sized - they use project defaults
new_page.manually_sized = False
self.project.add_page(new_page)
# Insert the page at the calculated position
self.project.add_page(new_page, index=insert_index)
# Renumber all pages to ensure consistent numbering
# Page numbers represent logical page numbers in the book
current_page_num = 1
for page in self.project.pages:
page.page_number = current_page_num
current_page_num += page.get_page_count()
self.update_view()
print(f"Added page {new_page_number} with size {width_mm}×{height_mm} mm")
# Get display name for status message
new_page_name = self.project.get_page_display_name(new_page)
print(f"Added {new_page_name} at position {insert_index + 1} with size {width_mm}×{height_mm} mm")
@ribbon_action(
label="Page Setup",
@ -417,9 +454,12 @@ class PageOperationsMixin:
# Remove the selected page
self.project.remove_page(page_to_remove)
# Renumber remaining pages
for i, page in enumerate(self.project.pages):
page.page_number = i + 1
# Renumber remaining pages to ensure consistent numbering
# Page numbers represent logical page numbers in the book
current_page_num = 1
for page in self.project.pages:
page.page_number = current_page_num
current_page_num += page.get_page_count()
# Update display
self.update_view()

View File

@ -6,6 +6,8 @@ from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List
import json
import os
import uuid
from datetime import datetime, timezone
from PIL import Image
# Global configuration for asset path resolution
@ -40,6 +42,52 @@ class BaseLayoutElement(ABC):
self.rotation = rotation
self.z_index = z_index
# UUID for merge conflict resolution (v3.0+)
self.uuid = str(uuid.uuid4())
# Timestamps for merge conflict resolution (v3.0+)
now = datetime.now(timezone.utc).isoformat()
self.created = now
self.last_modified = now
# Deletion tracking for merge (v3.0+)
self.deleted = False
self.deleted_at: Optional[str] = None
def mark_modified(self):
"""Update the last_modified timestamp to now."""
self.last_modified = datetime.now(timezone.utc).isoformat()
def mark_deleted(self):
"""Mark this element as deleted."""
self.deleted = True
self.deleted_at = datetime.now(timezone.utc).isoformat()
self.mark_modified()
def _serialize_base_fields(self) -> Dict[str, Any]:
"""Serialize base fields common to all elements (v3.0+)."""
return {
"uuid": self.uuid,
"created": self.created,
"last_modified": self.last_modified,
"deleted": self.deleted,
"deleted_at": self.deleted_at,
}
def _deserialize_base_fields(self, data: Dict[str, Any]):
"""Deserialize base fields common to all elements (v3.0+)."""
# UUID (required in v3.0+, generate if missing for backwards compatibility)
self.uuid = data.get("uuid", str(uuid.uuid4()))
# Timestamps (required in v3.0+, use current time if missing)
now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now)
# Deletion tracking (default to not deleted)
self.deleted = data.get("deleted", False)
self.deleted_at = data.get("deleted_at", None)
@abstractmethod
def render(self):
"""Render the element using OpenGL"""
@ -262,10 +310,17 @@ class ImageData(BaseLayoutElement):
# Include image dimensions metadata if available
if self.image_dimensions:
data["image_dimensions"] = self.image_dimensions
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
@ -417,7 +472,7 @@ class PlaceholderData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize placeholder data to dictionary"""
return {
data = {
"type": "placeholder",
"position": self.position,
"size": self.size,
@ -426,9 +481,15 @@ class PlaceholderData(BaseLayoutElement):
"placeholder_type": self.placeholder_type,
"default_content": self.default_content
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
@ -498,7 +559,7 @@ class TextBoxData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize text box data to dictionary"""
return {
data = {
"type": "textbox",
"position": self.position,
"size": self.size,
@ -508,9 +569,15 @@ class TextBoxData(BaseLayoutElement):
"font_settings": self.font_settings,
"alignment": self.alignment
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.rotation = data.get("rotation", 0)
@ -584,15 +651,21 @@ class GhostPageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]:
"""Serialize ghost page data to dictionary"""
return {
data = {
"type": "ghostpage",
"position": self.position,
"size": self.size,
"page_size": self.page_size
}
# Add base fields (v3.0+)
data.update(self._serialize_base_fields())
return data
def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary"""
# Deserialize base fields first (v3.0+)
self._deserialize_base_fields(data)
self.position = tuple(data.get("position", (0, 0)))
self.size = tuple(data.get("size", (100, 100)))
self.page_size = tuple(data.get("page_size", (210, 297)))

View File

@ -4,6 +4,8 @@ Project and page management for pyPhotoAlbum
import os
import math
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Tuple
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory
@ -15,7 +17,7 @@ class Page:
def __init__(self, layout: Optional[PageLayout] = None, page_number: int = 1, is_double_spread: bool = False):
"""
Initialize a page.
Args:
layout: PageLayout instance (created automatically if None)
page_number: The page number (for spreads, this is the left page number)
@ -25,6 +27,18 @@ class Page:
self.is_cover = False
self.is_double_spread = is_double_spread
self.manually_sized = False # Track if user manually changed page size
# UUID for merge conflict resolution (v3.0+)
self.uuid = str(uuid.uuid4())
# Timestamps for merge conflict resolution (v3.0+)
now = datetime.now(timezone.utc).isoformat()
self.created = now
self.last_modified = now
# Deletion tracking for merge (v3.0+)
self.deleted = False
self.deleted_at: Optional[str] = None
# Create layout with appropriate width
if layout is None:
@ -63,12 +77,22 @@ class Page:
def get_page_count(self) -> int:
"""
Get the number of physical pages this represents.
Returns:
2 for spreads, 1 for single pages
"""
return 2 if self.is_double_spread else 1
def mark_modified(self):
"""Update the last_modified timestamp to now."""
self.last_modified = datetime.now(timezone.utc).isoformat()
def mark_deleted(self):
"""Mark this page as deleted."""
self.deleted = True
self.deleted_at = datetime.now(timezone.utc).isoformat()
self.mark_modified()
def render(self):
"""Render the entire page"""
print(f"Rendering page {self.page_number}")
@ -81,7 +105,13 @@ class Page:
"is_cover": self.is_cover,
"is_double_spread": self.is_double_spread,
"manually_sized": self.manually_sized,
"layout": self.layout.serialize()
"layout": self.layout.serialize(),
# v3.0+ fields
"uuid": self.uuid,
"created": self.created,
"last_modified": self.last_modified,
"deleted": self.deleted,
"deleted_at": self.deleted_at,
}
def deserialize(self, data: Dict[str, Any]):
@ -91,6 +121,14 @@ class Page:
self.is_double_spread = data.get("is_double_spread", False)
self.manually_sized = data.get("manually_sized", False)
# v3.0+ fields (with backwards compatibility)
self.uuid = data.get("uuid", str(uuid.uuid4()))
now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now)
self.deleted = data.get("deleted", False)
self.deleted_at = data.get("deleted_at", None)
layout_data = data.get("layout", {})
self.layout = PageLayout()
self.layout.deserialize(layout_data)
@ -110,6 +148,15 @@ class Project:
self.export_dpi = 300 # Default export DPI
self.page_spacing_mm = 10.0 # Default spacing between pages (1cm)
# Project ID for merge detection (v3.0+)
# Projects with same ID should be merged, different IDs should be concatenated
self.project_id = str(uuid.uuid4())
# Timestamps for project-level changes (v3.0+)
now = datetime.now(timezone.utc).isoformat()
self.created = now
self.last_modified = now
# Cover configuration
self.has_cover = False # Whether project has a cover
self.paper_thickness_mm = 0.2 # Paper thickness for spine calculation (default 0.2mm)
@ -145,6 +192,7 @@ class Project:
def mark_dirty(self):
"""Mark the project as having unsaved changes."""
self._dirty = True
self.mark_modified()
def mark_clean(self):
"""Mark the project as saved (no unsaved changes)."""
@ -154,9 +202,22 @@ class Project:
"""Check if the project has unsaved changes."""
return self._dirty
def add_page(self, page: Page):
"""Add a page to the project"""
self.pages.append(page)
def mark_modified(self):
"""Update the last_modified timestamp to now."""
self.last_modified = datetime.now(timezone.utc).isoformat()
def add_page(self, page: Page, index: Optional[int] = None):
"""
Add a page to the project.
Args:
page: The page to add
index: Optional index to insert at. If None, appends to end.
"""
if index is None:
self.pages.append(page)
else:
self.pages.insert(index, page)
# Update cover dimensions if we have a cover
if self.has_cover and self.pages:
self.update_cover_dimensions()
@ -352,7 +413,11 @@ class Project:
"show_snap_lines": self.show_snap_lines,
"pages": [page.serialize() for page in self.pages],
"history": self.history.serialize(),
"asset_manager": self.asset_manager.serialize()
"asset_manager": self.asset_manager.serialize(),
# v3.0+ fields
"project_id": self.project_id,
"created": self.created,
"last_modified": self.last_modified,
}
def deserialize(self, data: Dict[str, Any]):
@ -382,6 +447,12 @@ class Project:
self.snap_threshold_mm = data.get("snap_threshold_mm", 5.0)
self.show_grid = data.get("show_grid", False)
self.show_snap_lines = data.get("show_snap_lines", True)
# v3.0+ fields (with backwards compatibility)
self.project_id = data.get("project_id", str(uuid.uuid4()))
now = datetime.now(timezone.utc).isoformat()
self.created = data.get("created", now)
self.last_modified = data.get("last_modified", now)
self.pages = []

View File

@ -148,8 +148,8 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
# Create ZIP file
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Write project.json
project_json = json.dumps(project_data, indent=2)
# Write project.json with stable sorting for git-friendly diffs
project_json = json.dumps(project_data, indent=2, sort_keys=True)
zipf.writestr('project.json', project_json)
# Add all files from the assets folder
@ -171,7 +171,7 @@ def save_to_zip(project: Project, zip_path: str) -> Tuple[bool, Optional[str]]:
return False, error_msg
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Optional[Project], Optional[str]]:
def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Project:
"""
Load a project from a ZIP file.
@ -181,96 +181,85 @@ def load_from_zip(zip_path: str, extract_to: Optional[str] = None) -> Tuple[Opti
directory that will be cleaned up when the project is closed.
Returns:
Tuple of (project: Optional[Project], error_message: Optional[str])
Project instance (raises exception on error)
"""
try:
if not os.path.exists(zip_path):
return None, f"ZIP file not found: {zip_path}"
if not os.path.exists(zip_path):
raise FileNotFoundError(f"ZIP file not found: {zip_path}")
# Track if we created a temp directory
temp_dir_obj = None
# Track if we created a temp directory
temp_dir_obj = None
# Determine extraction directory
if extract_to is None:
# Create a temporary directory using TemporaryDirectory
# This will be attached to the Project and auto-cleaned on deletion
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name
else:
# Create extraction directory if it doesn't exist
os.makedirs(extract_to, exist_ok=True)
# Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
zipf.extractall(extract_to)
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
if not os.path.exists(project_json_path):
return None, "Invalid project file: project.json not found"
with open(project_json_path, 'r') as f:
project_data = json.load(f)
# Determine extraction directory
if extract_to is None:
# Create a temporary directory using TemporaryDirectory
# This will be attached to the Project and auto-cleaned on deletion
zip_basename = os.path.splitext(os.path.basename(zip_path))[0]
temp_dir_obj = tempfile.TemporaryDirectory(prefix=f"pyPhotoAlbum_{zip_basename}_")
extract_to = temp_dir_obj.name
else:
# Create extraction directory if it doesn't exist
os.makedirs(extract_to, exist_ok=True)
# Check version compatibility
# Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
# Extract ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
zipf.extractall(extract_to)
# Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
if not is_compatible:
return None, error_msg
# Load project.json
project_json_path = os.path.join(extract_to, 'project.json')
if not os.path.exists(project_json_path):
raise ValueError("Invalid project file: project.json not found")
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
try:
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
print(f"Migration completed successfully")
except Exception as e:
error_msg = f"Migration failed: {str(e)}"
print(error_msg)
return None, error_msg
elif file_version != CURRENT_DATA_VERSION:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
with open(project_json_path, 'r') as f:
project_data = json.load(f)
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project = Project(name=project_name, folder_path=extract_to)
# Check version compatibility
# Try new version field first, fall back to legacy field
file_version = project_data.get('data_version', project_data.get('serialization_version', '1.0'))
# Deserialize project data
project.deserialize(project_data)
# Check if version is compatible
is_compatible, error_msg = check_version_compatibility(file_version, zip_path)
if not is_compatible:
raise ValueError(error_msg)
# Update folder path to extraction location
project.folder_path = extract_to
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
# Apply migrations if needed
if VersionCompatibility.needs_migration(file_version):
print(f"Migrating project from version {file_version} to {CURRENT_DATA_VERSION}...")
project_data = DataMigration.migrate(project_data, file_version, CURRENT_DATA_VERSION)
print(f"Migration completed successfully")
elif file_version != CURRENT_DATA_VERSION:
print(f"Note: Loading project with version {file_version}, current version is {CURRENT_DATA_VERSION}")
# Attach temporary directory to project (if we created one)
# The TemporaryDirectory will auto-cleanup when the project is deleted
if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
print(f"Project loaded to temporary directory: {extract_to}")
# Create new project
project_name = project_data.get('name', 'Untitled Project')
project = Project(name=project_name, folder_path=extract_to)
# Normalize asset paths in all ImageData elements
# This fixes old projects that have absolute or wrong relative paths
_normalize_asset_paths(project, extract_to)
# Deserialize project data
project.deserialize(project_data)
# Set asset resolution context for ImageData rendering
# Include the directory containing the .ppz file as a search path
from pyPhotoAlbum.models import set_asset_resolution_context
zip_directory = os.path.dirname(os.path.abspath(zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
# Update folder path to extraction location
project.folder_path = extract_to
project.asset_manager.project_folder = extract_to
project.asset_manager.assets_folder = os.path.join(extract_to, "assets")
print(f"Project loaded from {zip_path} to {extract_to}")
print(f"Additional search path: {zip_directory}")
return project, None
except Exception as e:
error_msg = f"Error loading project: {str(e)}"
print(error_msg)
return None, error_msg
# Attach temporary directory to project (if we created one)
# The TemporaryDirectory will auto-cleanup when the project is deleted
if temp_dir_obj is not None:
project._temp_dir = temp_dir_obj
print(f"Project loaded to temporary directory: {extract_to}")
# Normalize asset paths in all ImageData elements
# This fixes old projects that have absolute or wrong relative paths
_normalize_asset_paths(project, extract_to)
# Set asset resolution context for ImageData rendering
# Include the directory containing the .ppz file as a search path
from pyPhotoAlbum.models import set_asset_resolution_context
zip_directory = os.path.dirname(os.path.abspath(zip_path))
set_asset_resolution_context(extract_to, additional_search_paths=[zip_directory])
print(f"Project loaded from {zip_path} to {extract_to}")
print(f"Additional search path: {zip_directory}")
return project
def get_project_info(zip_path: str) -> Optional[dict]:

View File

@ -7,7 +7,7 @@ import os
# Current data version - increment when making breaking changes to data format
CURRENT_DATA_VERSION = "2.0"
CURRENT_DATA_VERSION = "3.0"
# Version history and compatibility information
VERSION_HISTORY = {
@ -25,6 +25,17 @@ VERSION_HISTORY = {
"Added automatic path normalization for legacy projects"
],
"compatible_with": ["1.0", "2.0"], # 2.0 can read 1.0 with migration
},
"3.0": {
"description": "Added merge conflict resolution support with UUIDs, timestamps, and project IDs",
"released": "2025-01-22",
"breaking_changes": [
"Added required UUID fields to all pages and elements",
"Added created/last_modified timestamps to projects, pages, and elements",
"Added project_id for merge detection (same ID = merge, different ID = concatenate)",
"Added deletion tracking (deleted flag and deleted_at timestamp)",
],
"compatible_with": ["1.0", "2.0", "3.0"], # 3.0 can read older versions with migration
}
}
@ -171,6 +182,81 @@ def migrate_1_0_to_2_0(data: Dict[str, Any]) -> Dict[str, Any]:
return data
@DataMigration.register_migration("2.0", "3.0")
def migrate_2_0_to_3_0(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Migrate from version 2.0 to 3.0.
Main changes:
- Add UUIDs to all pages and elements
- Add timestamps (created, last_modified) to project, pages, and elements
- Add project_id to project
- Add deletion tracking (deleted, deleted_at) to pages and elements
"""
import uuid
from datetime import datetime, timezone
print("Migration 2.0 → 3.0: Adding UUIDs, timestamps, and project_id")
# Get current timestamp for migration
now = datetime.now(timezone.utc).isoformat()
# Add project-level fields
if "project_id" not in data:
data["project_id"] = str(uuid.uuid4())
print(f" Generated project_id: {data['project_id']}")
if "created" not in data:
data["created"] = now
if "last_modified" not in data:
data["last_modified"] = now
# Migrate pages
for page_data in data.get("pages", []):
# Add UUID
if "uuid" not in page_data:
page_data["uuid"] = str(uuid.uuid4())
# Add timestamps
if "created" not in page_data:
page_data["created"] = now
if "last_modified" not in page_data:
page_data["last_modified"] = now
# Add deletion tracking
if "deleted" not in page_data:
page_data["deleted"] = False
if "deleted_at" not in page_data:
page_data["deleted_at"] = None
# Migrate elements in page layout
layout_data = page_data.get("layout", {})
for element_data in layout_data.get("elements", []):
# Add UUID
if "uuid" not in element_data:
element_data["uuid"] = str(uuid.uuid4())
# Add timestamps
if "created" not in element_data:
element_data["created"] = now
if "last_modified" not in element_data:
element_data["last_modified"] = now
# Add deletion tracking
if "deleted" not in element_data:
element_data["deleted"] = False
if "deleted_at" not in element_data:
element_data["deleted_at"] = None
# Update version
data['data_version'] = "3.0"
print(f" Migrated {len(data.get('pages', []))} pages to v3.0")
return data
def check_version_compatibility(file_version: str, file_path: str = "") -> tuple[bool, Optional[str]]:
"""
Check version compatibility and provide user-friendly messages.

View File

@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify asset loading fix and version handling
"""
import os
import sys
from pyPhotoAlbum.project_serializer import load_from_zip
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.version_manager import format_version_info
# Path to test file
test_file = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz"
print("=" * 70)
print("Testing asset loading fix and version handling")
print("=" * 70)
print()
print(format_version_info())
print()
print("=" * 70)
print(f"Loading: {test_file}")
print("=" * 70)
print()
# Load project
project, error = load_from_zip(test_file)
if error:
print(f"ERROR: {error}")
sys.exit(1)
print(f"Project loaded: {project.name}")
print(f"Project folder: {project.folder_path}")
print(f"Assets folder: {project.asset_manager.assets_folder}")
print()
# Count assets
total_assets = 0
missing_assets = 0
found_assets = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
total_assets += 1
# Check if asset exists
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(project.folder_path, element.image_path)
if os.path.exists(full_path):
found_assets += 1
print(f"✓ Found: {element.image_path}")
else:
missing_assets += 1
print(f"✗ Missing: {element.image_path}")
print()
print(f"Results:")
print(f" Total assets: {total_assets}")
print(f" Found: {found_assets}")
print(f" Missing: {missing_assets}")
if missing_assets == 0:
print()
print("SUCCESS! All assets loaded correctly.")
sys.exit(0)
else:
print()
print(f"PARTIAL: {missing_assets} assets still missing.")
sys.exit(1)

View File

@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""
Test version round-trip: save with v2.0, load with v2.0 (no migration needed)
"""
import os
import sys
import tempfile
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
print("=" * 70)
print("Testing version round-trip (save v2.0, load v2.0)")
print("=" * 70)
print()
# Create a temporary directory for testing
temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_")
test_ppz = os.path.join(temp_dir, "test_project.ppz")
try:
# Create a new project
print("Creating new project...")
project = Project("Test Project")
print(f" Project folder: {project.folder_path}")
print()
# Save it
print(f"Saving to: {test_ppz}")
success, error = save_to_zip(project, test_ppz)
if not success:
print(f"ERROR: Failed to save: {error}")
sys.exit(1)
print(" Saved successfully!")
print()
# Load it back
print(f"Loading from: {test_ppz}")
loaded_project, error = load_from_zip(test_ppz)
if error:
print(f"ERROR: Failed to load: {error}")
sys.exit(1)
print(f" Loaded successfully!")
print(f" Project name: {loaded_project.name}")
print(f" Project folder: {loaded_project.folder_path}")
print()
# Check that it's version 2.0 and no migration was needed
print("Version check:")
print(f" Expected version: {CURRENT_DATA_VERSION}")
print(f" ✓ No migration was performed (would have been logged if needed)")
print()
print("=" * 70)
print("SUCCESS! Version round-trip test passed.")
print("=" * 70)
finally:
# Cleanup
import shutil
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(f"\nCleaned up test directory: {temp_dir}")

0
tests/test_alignment.py Normal file → Executable file
View File

0
tests/test_alignment_ops_mixin.py Normal file → Executable file
View File

0
tests/test_asset_drop_mixin.py Normal file → Executable file
View File

57
tests/test_asset_loading.py Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Test script to verify asset loading fix and version handling
"""
import os
import pytest
from pyPhotoAlbum.project_serializer import load_from_zip
from pyPhotoAlbum.models import ImageData
# Path to test file - this is a real file that may or may not exist
TEST_FILE = "/home/dtourolle/Nextcloud/Photo Gallery/gr58/Album_pytool.ppz"
@pytest.mark.skipif(not os.path.exists(TEST_FILE), reason=f"Test file not found: {TEST_FILE}")
def test_asset_loading_from_real_file():
"""Test asset loading from a real project file (if it exists)"""
# Load project
project = load_from_zip(TEST_FILE)
assert project is not None, "Failed to load project"
assert project.name is not None, "Project has no name"
assert project.folder_path is not None, "Project has no folder path"
assert project.asset_manager.assets_folder is not None, "Project has no assets folder"
# Count assets
total_assets = 0
missing_assets = 0
found_assets = 0
for page in project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
total_assets += 1
# Check if asset exists
if os.path.isabs(element.image_path):
full_path = element.image_path
else:
full_path = os.path.join(project.folder_path, element.image_path)
if os.path.exists(full_path):
found_assets += 1
else:
missing_assets += 1
print(f"Missing asset: {element.image_path}")
# Report results
print(f"\nResults:")
print(f" Total assets: {total_assets}")
print(f" Found: {found_assets}")
print(f" Missing: {missing_assets}")
# The test passes as long as we can load the project
# Missing assets are acceptable (they might be on a different machine)
assert total_assets >= 0, "Should have counted assets"

0
tests/test_base_mixin.py Normal file → Executable file
View File

0
tests/test_commands.py Normal file → Executable file
View File

0
tests/test_distribution_ops_mixin.py Normal file → Executable file
View File

0
test_drop_bug.py → tests/test_drop_bug.py Normal file → Executable file
View File

0
tests/test_edit_ops_mixin.py Normal file → Executable file
View File

0
tests/test_element_manipulation_mixin.py Normal file → Executable file
View File

0
tests/test_element_ops_mixin.py Normal file → Executable file
View File

0
tests/test_element_selection_mixin.py Normal file → Executable file
View File

0
tests/test_embedded_templates.py Normal file → Executable file
View File

0
tests/test_gl_widget_fixtures.py Normal file → Executable file
View File

0
tests/test_gl_widget_integration.py Normal file → Executable file
View File

View File

@ -63,13 +63,13 @@ def test_heal_external_paths():
print(f" ✅ Assets: {asset_files}")
# Load the project
loaded_project, error = load_from_zip(zip_path)
if not loaded_project:
try:
loaded_project = load_from_zip(zip_path)
print(f"\n5. Loaded project from zip")
except Exception as error:
print(f" ❌ Failed to load: {error}")
return False
print(f"\n5. Loaded project from zip")
# Check for missing assets
from pyPhotoAlbum.models import ImageData as ImageDataCheck
missing_count = 0

0
tests/test_image_pan_mixin.py Normal file → Executable file
View File

0
tests/test_interaction_undo_mixin.py Normal file → Executable file
View File

View File

271
tests/test_merge.py Executable file
View File

@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
Test script for project merge functionality
This script creates two versions of a project, modifies them differently,
and tests the merge functionality.
"""
import os
import sys
import tempfile
from datetime import datetime, timezone, timedelta
# Add pyPhotoAlbum to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.models import ImageData, TextBoxData
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.merge_manager import MergeManager, MergeStrategy, concatenate_projects
def create_base_project():
"""Create a base project with some content"""
project = Project("Base Project")
# Add a page with text
page = Page(page_number=1)
text = TextBoxData(
text_content="Original Text",
x=10, y=10, width=100, height=50
)
page.layout.add_element(text)
project.add_page(page)
return project
def test_same_project_merge():
"""Test merging two versions of the same project"""
print("=" * 60)
print("Test 1: Merging Same Project (with conflicts)")
print("=" * 60)
with tempfile.TemporaryDirectory() as temp_dir:
# Create base project
print("\n1. Creating base project...")
base_project = create_base_project()
base_file = os.path.join(temp_dir, "base.ppz")
success, _ = save_to_zip(base_project, base_file)
assert success, "Failed to save base project"
print(f" ✓ Base project saved with project_id: {base_project.project_id}")
# Load base project twice to create two versions
print("\n2. Creating two divergent versions...")
# Version A: Modify text content
project_a = load_from_zip(base_file)
text_a = project_a.pages[0].layout.elements[0]
text_a.text_content = "Modified by User A"
text_a.mark_modified()
version_a_file = os.path.join(temp_dir, "version_a.ppz")
save_to_zip(project_a, version_a_file)
print(f" ✓ Version A: Modified text to '{text_a.text_content}'")
# Version B: Modify text position
project_b = load_from_zip(base_file)
text_b = project_b.pages[0].layout.elements[0]
text_b.position = (50, 50)
text_b.mark_modified()
version_b_file = os.path.join(temp_dir, "version_b.ppz")
save_to_zip(project_b, version_b_file)
print(f" ✓ Version B: Modified position to {text_b.position}")
# Detect conflicts
print("\n3. Detecting conflicts...")
merge_manager = MergeManager()
data_a = project_a.serialize()
data_b = project_b.serialize()
should_merge = merge_manager.should_merge_projects(data_a, data_b)
assert should_merge, "Projects should be merged (same project_id)"
print(f" ✓ Projects have same project_id, will merge")
conflicts = merge_manager.detect_conflicts(data_a, data_b)
print(f" ✓ Found {len(conflicts)} conflict(s)")
for i, conflict in enumerate(conflicts):
print(f" - Conflict {i+1}: {conflict.description}")
# Auto-resolve using LATEST_WINS strategy
print("\n4. Auto-resolving with LATEST_WINS strategy...")
resolutions = merge_manager.auto_resolve_conflicts(MergeStrategy.LATEST_WINS)
print(f" ✓ Resolutions: {resolutions}")
# Apply merge
merged_data = merge_manager.apply_resolutions(data_a, data_b, resolutions)
print(f" ✓ Merge applied successfully")
print(f" ✓ Merged project has {len(merged_data['pages'])} page(s)")
print(f"\n{'=' * 60}")
print("✅ Same project merge test PASSED")
print(f"{'=' * 60}\n")
return True
def test_different_project_concatenation():
"""Test concatenating two different projects"""
print("=" * 60)
print("Test 2: Concatenating Different Projects")
print("=" * 60)
with tempfile.TemporaryDirectory() as temp_dir:
# Create two different projects
print("\n1. Creating two different projects...")
project_a = Project("Project A")
page_a = Page(page_number=1)
text_a = TextBoxData(text_content="From Project A", x=10, y=10, width=100, height=50)
page_a.layout.add_element(text_a)
project_a.add_page(page_a)
project_b = Project("Project B")
page_b = Page(page_number=1)
text_b = TextBoxData(text_content="From Project B", x=10, y=10, width=100, height=50)
page_b.layout.add_element(text_b)
project_b.add_page(page_b)
print(f" ✓ Project A: project_id={project_a.project_id}")
print(f" ✓ Project B: project_id={project_b.project_id}")
# Check if should merge
print("\n2. Checking merge vs concatenate...")
merge_manager = MergeManager()
data_a = project_a.serialize()
data_b = project_b.serialize()
should_merge = merge_manager.should_merge_projects(data_a, data_b)
assert not should_merge, "Projects should be concatenated (different project_ids)"
print(f" ✓ Projects have different project_ids, will concatenate")
# Concatenate
print("\n3. Concatenating projects...")
merged_data = concatenate_projects(data_a, data_b)
assert len(merged_data['pages']) == 2, "Should have 2 pages"
print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages")
print(f" ✓ Combined name: {merged_data['name']}")
print(f"\n{'=' * 60}")
print("✅ Project concatenation test PASSED")
print(f"{'=' * 60}\n")
return True
def test_no_conflicts():
"""Test merging when there are no conflicts"""
print("=" * 60)
print("Test 3: Merging Without Conflicts")
print("=" * 60)
with tempfile.TemporaryDirectory() as temp_dir:
# Create base project with 2 pages
print("\n1. Creating base project with 2 pages...")
base_project = Project("Multi-Page Project")
page1 = Page(page_number=1)
text1 = TextBoxData(text_content="Page 1", x=10, y=10, width=100, height=50)
page1.layout.add_element(text1)
base_project.add_page(page1)
page2 = Page(page_number=2)
text2 = TextBoxData(text_content="Page 2", x=10, y=10, width=100, height=50)
page2.layout.add_element(text2)
base_project.add_page(page2)
base_file = os.path.join(temp_dir, "base.ppz")
save_to_zip(base_project, base_file)
print(f" ✓ Base project saved with 2 pages")
# Version A: Modify page 1
project_a = load_from_zip(base_file)
project_a.pages[0].layout.elements[0].text_content = "Page 1 - Modified by A"
project_a.pages[0].layout.elements[0].mark_modified()
# Version B: Modify page 2 (different page, no conflict)
project_b = load_from_zip(base_file)
project_b.pages[1].layout.elements[0].text_content = "Page 2 - Modified by B"
project_b.pages[1].layout.elements[0].mark_modified()
print(f" ✓ Version A modified page 1")
print(f" ✓ Version B modified page 2")
# Detect conflicts
print("\n2. Detecting conflicts...")
merge_manager = MergeManager()
data_a = project_a.serialize()
data_b = project_b.serialize()
conflicts = merge_manager.detect_conflicts(data_a, data_b)
print(f" ✓ Found {len(conflicts)} conflict(s)")
# Should be able to auto-merge
print("\n3. Auto-merging non-conflicting changes...")
merged_data = merge_manager.apply_resolutions(data_a, data_b, {})
# Verify both changes are present
merged_project = Project()
merged_project.deserialize(merged_data)
assert len(merged_project.pages) == 2, "Should have 2 pages"
page1_text = merged_project.pages[0].layout.elements[0].text_content
page2_text = merged_project.pages[1].layout.elements[0].text_content
assert "Modified by A" in page1_text, "Page 1 changes missing"
assert "Modified by B" in page2_text, "Page 2 changes missing"
print(f" ✓ Page 1 text: {page1_text}")
print(f" ✓ Page 2 text: {page2_text}")
print(f" ✓ Both changes preserved in merge")
print(f"\n{'=' * 60}")
print("✅ No-conflict merge test PASSED")
print(f"{'=' * 60}\n")
return True
def run_all_tests():
"""Run all merge tests"""
print("\n" + "=" * 60)
print("PYPH OTOALBUM MERGE FUNCTIONALITY TESTS")
print("=" * 60 + "\n")
tests = [
("Same Project Merge", test_same_project_merge),
("Different Project Concatenation", test_different_project_concatenation),
("No-Conflict Merge", test_no_conflicts),
]
results = []
for name, test_func in tests:
try:
success = test_func()
results.append((name, success))
except Exception as e:
print(f"\n❌ Test '{name}' FAILED with exception: {e}")
import traceback
traceback.print_exc()
results.append((name, False))
# Print summary
print("\n" + "=" * 60)
print("TEST SUMMARY")
print("=" * 60)
for name, success in results:
status = "✅ PASS" if success else "❌ FAIL"
print(f"{status}: {name}")
all_passed = all(success for _, success in results)
print("=" * 60)
print(f"\nOverall: {'✅ ALL TESTS PASSED' if all_passed else '❌ SOME TESTS FAILED'}\n")
return all_passed
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

173
tests/test_migration.py Executable file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Test script for v2.0 to v3.0 migration
This script creates a v2.0 project, saves it, then loads it back
to verify that the migration to v3.0 works correctly.
"""
import os
import sys
import tempfile
import json
import zipfile
from datetime import datetime
# Add pyPhotoAlbum to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.models import ImageData, TextBoxData
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
def create_v2_project_json():
"""Create a v2.0 project JSON (without UUIDs, timestamps, project_id)"""
return {
"name": "Test Project v2.0",
"folder_path": "./test_project",
"page_size_mm": [140, 140],
"working_dpi": 300,
"export_dpi": 300,
"has_cover": False,
"data_version": "2.0",
"pages": [
{
"page_number": 1,
"is_cover": False,
"is_double_spread": False,
"manually_sized": False,
"layout": {
"size": [140, 140],
"background_color": [1.0, 1.0, 1.0],
"elements": [
{
"type": "textbox",
"position": [10, 10],
"size": [100, 50],
"rotation": 0,
"z_index": 0,
"text_content": "Hello v2.0",
"font_settings": {
"family": "Arial",
"size": 12,
"color": [0, 0, 0]
},
"alignment": "left"
}
],
"snapping_system": {
"snap_threshold_mm": 5.0,
"grid_size_mm": 10.0
}
}
}
],
"history": {
"undo_stack": [],
"redo_stack": [],
"max_history": 100
},
"asset_manager": {
"reference_counts": {}
}
}
def test_migration():
"""Test v2.0 to v3.0 migration"""
print("=" * 60)
print("Testing v2.0 to v3.0 Migration")
print("=" * 60)
with tempfile.TemporaryDirectory() as temp_dir:
# Create a fake v2.0 .ppz file
v2_file = os.path.join(temp_dir, "test_v2.ppz")
print(f"\n1. Creating v2.0 project file: {v2_file}")
v2_data = create_v2_project_json()
with zipfile.ZipFile(v2_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
project_json = json.dumps(v2_data, indent=2)
zipf.writestr('project.json', project_json)
print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)")
print(f" ✓ Version: {v2_data['data_version']}")
# Load the v2.0 file (should trigger migration)
print(f"\n2. Loading v2.0 project (migration should occur)...")
try:
project = load_from_zip(v2_file)
print(f" ✓ Project loaded successfully")
print(f" ✓ Project name: {project.name}")
# Verify migration
print(f"\n3. Verifying migration to v3.0...")
# Check project-level fields
assert hasattr(project, 'project_id'), "Missing project_id"
assert hasattr(project, 'created'), "Missing created timestamp"
assert hasattr(project, 'last_modified'), "Missing last_modified timestamp"
print(f" ✓ Project has project_id: {project.project_id}")
print(f" ✓ Project has created: {project.created}")
print(f" ✓ Project has last_modified: {project.last_modified}")
# Check page-level fields
assert len(project.pages) > 0, "No pages in project"
page = project.pages[0]
assert hasattr(page, 'uuid'), "Page missing uuid"
assert hasattr(page, 'created'), "Page missing created"
assert hasattr(page, 'last_modified'), "Page missing last_modified"
assert hasattr(page, 'deleted'), "Page missing deleted flag"
print(f" ✓ Page 1 has uuid: {page.uuid}")
print(f" ✓ Page 1 has timestamps and deletion tracking")
# Check element-level fields
assert len(page.layout.elements) > 0, "No elements in page"
element = page.layout.elements[0]
assert hasattr(element, 'uuid'), "Element missing uuid"
assert hasattr(element, 'created'), "Element missing created"
assert hasattr(element, 'last_modified'), "Element missing last_modified"
assert hasattr(element, 'deleted'), "Element missing deleted flag"
print(f" ✓ Element has uuid: {element.uuid}")
print(f" ✓ Element has timestamps and deletion tracking")
# Save as v3.0 and verify
print(f"\n4. Saving migrated project as v3.0...")
v3_file = os.path.join(temp_dir, "test_v3.ppz")
success, error = save_to_zip(project, v3_file)
assert success, f"Save failed: {error}"
print(f" ✓ Saved to: {v3_file}")
# Verify v3.0 file structure
with zipfile.ZipFile(v3_file, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
v3_data = json.loads(project_json)
assert v3_data.get('data_version') == "3.0", "Wrong version"
assert 'project_id' in v3_data, "Missing project_id in saved file"
assert 'created' in v3_data, "Missing created in saved file"
assert 'uuid' in v3_data['pages'][0], "Missing page uuid in saved file"
print(f" ✓ Saved file version: {v3_data.get('data_version')}")
print(f" ✓ All v3.0 fields present in saved file")
print(f"\n{'=' * 60}")
print("✅ Migration test PASSED")
print(f"{'=' * 60}\n")
return True
except Exception as e:
print(f"\n{'=' * 60}")
print(f"❌ Migration test FAILED: {e}")
print(f"{'=' * 60}\n")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
success = test_migration()
sys.exit(0 if success else 1)

0
tests/test_models.py Normal file → Executable file
View File

7
tests/test_mouse_interaction_mixin.py Normal file → Executable file
View File

@ -265,6 +265,7 @@ class TestMouseMoveEvent:
qtbot.addWidget(widget)
widget.update = Mock()
widget.clamp_pan_offset = Mock() # Mock clamping to allow any pan offset
# Start panning
widget.is_panning = True
@ -279,7 +280,9 @@ class TestMouseMoveEvent:
# Pan offset should have changed
assert widget.pan_offset != initial_pan
assert widget.pan_offset == [50.0, 50.0] # Moved by 50 pixels in each direction
assert widget.update.called
assert widget.clamp_pan_offset.called # Clamping should be called
def test_ctrl_drag_pans_image_in_frame(self, qtbot):
"""Test Ctrl+drag pans image within frame"""
@ -473,6 +476,8 @@ class TestWheelEvent:
qtbot.addWidget(widget)
widget.update = Mock()
# Mock clamp_pan_offset to prevent it from resetting pan_offset
widget.clamp_pan_offset = Mock()
initial_pan = widget.pan_offset[1]
@ -492,6 +497,8 @@ class TestWheelEvent:
qtbot.addWidget(widget)
widget.update = Mock()
# Mock clamp_pan_offset to prevent it from resetting pan_offset
widget.clamp_pan_offset = Mock()
initial_pan = widget.pan_offset[1]

42
test_multiselect.py → tests/test_multiselect.py Normal file → Executable file
View File

@ -3,19 +3,27 @@
Test script to verify multiselect visual feedback functionality
"""
import sys
import pytest
from unittest.mock import Mock, patch, MagicMock
from PyQt6.QtWidgets import QApplication
from pyPhotoAlbum.gl_widget import GLWidget
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.rendering import RenderingMixin
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
def test_multiselect_visual_feedback():
"""Test that all selected elements get selection handles drawn"""
# Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing"""
def __init__(self):
super().__init__()
self._page_renderers = []
self.rotation_mode = False # Required by _draw_selection_handles
print("Testing multiselect visual feedback...")
def test_multiselect_visual_feedback(qtbot):
"""Test that all selected elements get selection handles drawn"""
# Create a project with a page
project = Project("Test Project")
@ -23,8 +31,9 @@ def test_multiselect_visual_feedback():
page = Page(layout=page_layout, page_number=1)
project.add_page(page)
# Create GL widget
widget = GLWidget()
# Create test widget and add to qtbot for proper lifecycle management
widget = MultiSelectTestWidget()
qtbot.addWidget(widget)
# Mock the main window to return our project
mock_window = Mock()
@ -131,14 +140,15 @@ def test_multiselect_visual_feedback():
print("\n✓ All multiselect visual feedback tests passed!")
def test_regression_old_code_bug():
def test_regression_old_code_bug(qtbot):
"""
Regression test: Verify the old bug (only first element gets handles)
would have been caught by this test
"""
print("\nRegression test: Simulating old buggy behavior...")
widget = GLWidget()
widget = MultiSelectTestWidget()
qtbot.addWidget(widget)
# Create mock elements
element1 = Mock()
@ -173,15 +183,3 @@ def test_regression_old_code_bug():
assert call_count_new == 3, "New code should handle all 3 elements"
print("✓ Regression test confirms the bug would have been caught!")
if __name__ == "__main__":
# Initialize Qt application (needed for PyQt6 widgets)
app = QApplication(sys.argv)
test_multiselect_visual_feedback()
test_regression_old_code_bug()
print("\n" + "="*60)
print("All tests completed successfully!")
print("="*60)

0
tests/test_page_layout.py Normal file → Executable file
View File

0
tests/test_page_navigation_mixin.py Normal file → Executable file
View File

110
tests/test_page_ops_mixin.py Normal file → Executable file
View File

@ -300,19 +300,112 @@ class TestAddPage:
assert window._update_view_called
def test_add_page_to_existing_pages(self, qtbot):
"""Test adds page to project with existing pages"""
"""Test adds page after the current page"""
window = TestPageOpsWindow()
qtbot.addWidget(window)
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
window.project.pages = [page1]
# Mock _get_most_visible_page_index to return page 1 (index 0)
mock_renderer = Mock()
mock_renderer.screen_y = 100
window.gl_widget._page_renderers = [(mock_renderer, page1)]
window.add_page()
assert len(window.project.pages) == 2
# New page should be inserted after page 1
assert window.project.pages[0].page_number == 1
assert window.project.pages[1].page_number == 2
assert window._update_view_called
def test_add_page_inserts_after_current_page(self, qtbot):
"""Test adds page after the currently visible page, not at the end"""
window = TestPageOpsWindow()
qtbot.addWidget(window)
# Create three pages
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
window.project.pages = [page1, page2, page3]
# Mock _get_most_visible_page_index to return page 2 (index 1)
window.gl_widget.height = Mock(return_value=600)
renderer1 = Mock()
renderer1.screen_y = 50
renderer2 = Mock()
renderer2.screen_y = -300 # Page 2 is most visible
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.add_page()
assert len(window.project.pages) == 4
# Verify pages are in correct order (physical order in list)
# After inserting after page2 (index 1), the new page is at index 2
assert window.project.pages[0] == page1
assert window.project.pages[1] == page2
# window.project.pages[2] is the new page
assert window.project.pages[3] == page3
# Page numbers should be renumbered sequentially
assert window.project.pages[0].page_number == 1
assert window.project.pages[1].page_number == 2
assert window.project.pages[2].page_number == 3 # New page
assert window.project.pages[3].page_number == 4 # Old page 3, renumbered
assert window._update_view_called
def test_add_page_with_double_spreads(self, qtbot):
"""Test page numbering with double spreads"""
window = TestPageOpsWindow()
qtbot.addWidget(window)
# Create pages: single, double spread, single
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page1.is_double_spread = False
page2 = Page(layout=PageLayout(width=420, height=297), page_number=2)
page2.is_double_spread = True
page2.layout.is_facing_page = True
page3 = Page(layout=PageLayout(width=210, height=297), page_number=4)
page3.is_double_spread = False
window.project.pages = [page1, page2, page3]
# Mock renderers - page 2 is most visible
window.gl_widget.height = Mock(return_value=600)
renderer1 = Mock()
renderer1.screen_y = 800
renderer2 = Mock()
renderer2.screen_y = -300 # Page 2 (double spread) is most visible
renderer3 = Mock()
renderer3.screen_y = 1500
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.add_page()
assert len(window.project.pages) == 4
# Page numbers should account for double spread:
# page1: 1 (single)
# page2: 2-3 (double spread, counts as 2 pages)
# new_page: 4 (single)
# page3: 5 (was 4, renumbered)
assert window.project.pages[0].page_number == 1
assert window.project.pages[1].page_number == 2 # Double spread starts at 2
assert window.project.pages[2].page_number == 4 # New page after double spread
assert window.project.pages[3].page_number == 5 # Old page3 renumbered
class TestRemovePage:
"""Test remove_page method"""
@ -356,6 +449,21 @@ class TestRemovePage:
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
window.project.pages = [page1, page2, page3]
# Mock renderers to make page3 the most visible (so it gets removed)
window.gl_widget.height = Mock(return_value=600)
renderer1 = Mock()
renderer1.screen_y = 800
renderer2 = Mock()
renderer2.screen_y = 600
renderer3 = Mock()
renderer3.screen_y = -300 # Page 3 is most visible
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.remove_page()
assert len(window.project.pages) == 2

0
tests/test_page_renderer.py Normal file → Executable file
View File

0
test_page_setup.py → tests/test_page_setup.py Normal file → Executable file
View File

0
tests/test_pdf_export.py Normal file → Executable file
View File

0
tests/test_project.py Normal file → Executable file
View File

63
tests/test_project_serialization.py Normal file → Executable file
View File

@ -76,10 +76,10 @@ class TestBasicSerialization:
zip_path = os.path.join(temp_dir, "empty_project.ppz")
save_to_zip(sample_project, zip_path)
loaded_project, error = load_from_zip(zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert error is None
assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297)
assert loaded_project.working_dpi == 300
@ -88,12 +88,13 @@ class TestBasicSerialization:
def test_load_nonexistent_file(self, temp_dir):
"""Test loading from a non-existent file"""
zip_path = os.path.join(temp_dir, "nonexistent.ppz")
loaded_project, error = load_from_zip(zip_path)
assert loaded_project is None
assert error is not None
assert "not found" in error.lower()
try:
loaded_project = load_from_zip(zip_path)
assert False, "Should have raised an exception"
except Exception as error:
assert error is not None
assert "not found" in str(error).lower()
def test_save_project_with_pages(self, sample_project, temp_dir):
"""Test saving a project with multiple pages"""
@ -120,7 +121,7 @@ class TestBasicSerialization:
# Save and load
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
save_to_zip(sample_project, zip_path)
loaded_project, error = load_from_zip(zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 3
@ -162,7 +163,7 @@ class TestZipStructure:
data = json.loads(project_json)
assert 'serialization_version' in data
assert data['serialization_version'] == "2.0"
assert data['serialization_version'] == "3.0"
class TestAssetManagement:
@ -225,7 +226,7 @@ class TestAssetManagement:
# Save and load
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path)
loaded_project, error = load_from_zip(zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 1
@ -261,7 +262,7 @@ class TestAssetManagement:
# Save and load
zip_path = os.path.join(temp_dir, "project_refs.ppz")
save_to_zip(sample_project, zip_path)
loaded_project, error = load_from_zip(zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
# Reference counts should be preserved
@ -287,7 +288,7 @@ class TestPortability:
# Load to a different location
new_location = os.path.join(temp_dir, "different_location")
loaded_project, error = load_from_zip(zip_path, extract_to=new_location)
loaded_project = load_from_zip(zip_path, extract_to=new_location)
assert loaded_project is not None
assert loaded_project.folder_path == new_location
@ -313,7 +314,7 @@ class TestPortability:
# Load to different location
new_location = os.path.join(temp_dir, "new_location")
loaded_project, error = load_from_zip(zip_path, extract_to=new_location)
loaded_project = load_from_zip(zip_path, extract_to=new_location)
# Verify image path is accessible from new location
img_element = loaded_project.pages[0].layout.elements[0]
@ -349,7 +350,7 @@ class TestProjectInfo:
assert info is not None
assert info['name'] == "Test Project"
assert info['page_count'] == 5
assert info['version'] == "2.0"
assert info['version'] == "3.0"
assert info['working_dpi'] == 300
def test_get_info_invalid_zip(self, temp_dir):
@ -380,24 +381,34 @@ class TestEdgeCases:
with open(corrupted_path, 'w') as f:
f.write("This is not a ZIP file")
loaded_project, error = load_from_zip(corrupted_path)
try:
assert loaded_project is None
assert error is not None
loaded_project = load_from_zip(corrupted_path)
assert False, "Should have raised an exception"
except Exception as error:
assert error is not None
def test_load_zip_without_project_json(self, temp_dir):
"""Test loading a ZIP without project.json"""
zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json
with zipfile.ZipFile(zip_path, 'w') as zipf:
zipf.writestr('dummy.txt', 'dummy content')
loaded_project, error = load_from_zip(zip_path)
assert loaded_project is None
assert error is not None
assert "project.json not found" in error
try:
loaded_project = load_from_zip(zip_path)
assert False, "Should have raised an exception"
except Exception as error:
assert error is not None
assert "project.json not found" in str(error)
def test_project_with_text_elements(self, sample_project, temp_dir):
"""Test saving/loading project with text elements"""
@ -414,7 +425,7 @@ class TestEdgeCases:
# Save and load
zip_path = os.path.join(temp_dir, "with_text.ppz")
save_to_zip(sample_project, zip_path)
loaded_project, error = load_from_zip(zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 1

0
tests/test_rotation_serialization.py Normal file → Executable file
View File

0
tests/test_size_ops_mixin.py Normal file → Executable file
View File

0
tests/test_snapping.py Normal file → Executable file
View File

0
tests/test_template_manager.py Normal file → Executable file
View File

40
tests/test_version_roundtrip.py Executable file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
Test version round-trip: save with current version, load with current version (no migration needed)
"""
import os
import tempfile
import shutil
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip
from pyPhotoAlbum.version_manager import CURRENT_DATA_VERSION
def test_version_roundtrip():
"""Test that we can save and load a project without migration"""
# Create a temporary directory for testing
temp_dir = tempfile.mkdtemp(prefix="pyphotos_test_")
test_ppz = os.path.join(temp_dir, "test_project.ppz")
try:
# Create a new project
project = Project("Test Project")
# Save it
success, error = save_to_zip(project, test_ppz)
assert success, f"Failed to save: {error}"
assert os.path.exists(test_ppz), f"ZIP file not created: {test_ppz}"
# Load it back
loaded_project = load_from_zip(test_ppz)
# Verify the loaded project
assert loaded_project is not None, "Failed to load project"
assert loaded_project.name == "Test Project", f"Project name mismatch: {loaded_project.name}"
assert loaded_project.folder_path is not None, "Project folder path is None"
finally:
# Cleanup
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)

0
tests/test_view_ops_mixin.py Normal file → Executable file
View File

0
tests/test_viewport_mixin.py Normal file → Executable file
View File

View File

@ -101,14 +101,14 @@ def test_zip_embedding():
# Load the project back
print("\n5. Loading project from zip...")
loaded_project, error = load_from_zip(zip_path)
if loaded_project is None:
print(f" ✗ ERROR: Failed to load: {error}")
try:
loaded_project = load_from_zip(zip_path)
print(f" ✓ Loaded project: {loaded_project.name}")
except Exception as e:
print(f" ✗ ERROR: Failed to load: {e}")
os.unlink(zip_path)
return False
print(f" ✓ Loaded project: {loaded_project.name}")
# Check that the image is accessible
print("\n6. Verifying loaded image...")
if loaded_project.pages and loaded_project.pages[0].layout.elements:

0
tests/test_zorder.py Normal file → Executable file
View File

0
tests/test_zorder_ops_mixin.py Normal file → Executable file
View File