diff --git a/pyproject.toml b/pyproject.toml index 0a1b086..d0c4757 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,32 @@ - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +[project] +name = "pilgrim" +version = "0.0.5" +authors = [ + { name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" } +] +description = "Pilgrim's Travel Log" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", +] +dependencies = [ + "sqlalchemy", + "textual", + "tomli", + "tomli_w", + "unidecode" +] - [project] - name = "Pilgrim" - version = "0.0.4" - authors = [ - { name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" } - ] - description = "Pilgrim's Travel Log" - readme = "README.md" - requires-python = ">=3.10" - classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - ] - dependencies = [ - "sqlalchemy", - "textual", - "tomli", - "tomli_w" +[project.urls] +Homepage = "https://github.com/gmbrax/Pilgrim/" +Issues = "https://github.com/gmbrax/Pilgrim/issues" - - ] - [template.plugins.default] - src-layout = true - [project.urls] - Homepage = "https://github.com/gmbrax/Pilgrim/" - Issues = "https://github.com/gmbrax/Pilgrim/issues" - [tool.hatch.build.targets.wheel] - packages = ["src/pilgrim"] - [project.scripts] - pilgrim = "pilgrim:main" +[project.scripts] +pilgrim = "pilgrim.command:main" \ No newline at end of file diff --git a/src/pilgrim/database.py b/src/pilgrim/database.py index 8d472af..44ca8e9 100644 --- a/src/pilgrim/database.py +++ b/src/pilgrim/database.py @@ -1,5 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker from pathlib import Path import os diff --git a/src/pilgrim/models/entry.py b/src/pilgrim/models/entry.py index dac87b0..7835067 100644 --- a/src/pilgrim/models/entry.py +++ b/src/pilgrim/models/entry.py @@ -4,15 +4,16 @@ from pilgrim.models.photo_in_entry import photo_entry_association from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy.orm import relationship -from ..database import Base +from pilgrim.database import Base + class Entry(Base): __tablename__ = "entries" id = Column(Integer, primary_key=True) - title = Column(String) + title = Column(String,nullable=False) text = Column(String) - date = Column(DateTime) + date = Column(DateTime,nullable=False) photos = relationship( "Photo", secondary=photo_entry_association, diff --git a/src/pilgrim/models/photo.py b/src/pilgrim/models/photo.py index 3a1ac9b..88fc5a2 100644 --- a/src/pilgrim/models/photo.py +++ b/src/pilgrim/models/photo.py @@ -7,7 +7,8 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql.schema import Index from pilgrim.models.photo_in_entry import photo_entry_association -from ..database import Base +from pilgrim.database import Base + class Photo(Base): @@ -24,7 +25,8 @@ class Photo(Base): back_populates="photos" ) - fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) + fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"), nullable=False) + travel_diary = relationship("TravelDiary", back_populates="photos") __table_args__ = ( Index('idx_photo_hash_diary', 'hash', 'fk_travel_diary_id'), ) diff --git a/src/pilgrim/models/photo_in_entry.py b/src/pilgrim/models/photo_in_entry.py index 76f452e..8e9d677 100644 --- a/src/pilgrim/models/photo_in_entry.py +++ b/src/pilgrim/models/photo_in_entry.py @@ -1,6 +1,6 @@ from sqlalchemy import Table, Column, Integer, ForeignKey -from ..database import Base +from pilgrim.database import Base photo_entry_association = Table('photo_entry_association', Base.metadata, Column('id', Integer, primary_key=True, autoincrement=True), diff --git a/src/pilgrim/models/travel_diary.py b/src/pilgrim/models/travel_diary.py index 1164bc4..90a89b5 100644 --- a/src/pilgrim/models/travel_diary.py +++ b/src/pilgrim/models/travel_diary.py @@ -3,15 +3,17 @@ from typing import Any from sqlalchemy import Column, Integer, String, UniqueConstraint from sqlalchemy.orm import relationship -from .. import database +from pilgrim.database import Base -class TravelDiary(database.Base): + +class TravelDiary(Base): __tablename__ = "travel_diaries" id = Column(Integer, primary_key=True) name = Column(String, nullable=False) directory_name = Column(String, nullable=False, unique=True) entries = relationship("Entry", back_populates="travel_diary", cascade="all, delete-orphan") + photos = relationship("Photo", back_populates="travel_diary", cascade="all, delete-orphan") __table_args__ = ( UniqueConstraint('directory_name', name='uq_travel_diary_directory_name'), diff --git a/src/pilgrim/service/entry_service.py b/src/pilgrim/service/entry_service.py index 040f5a8..1d6f476 100644 --- a/src/pilgrim/service/entry_service.py +++ b/src/pilgrim/service/entry_service.py @@ -1,9 +1,10 @@ +import re from datetime import datetime from typing import List -from ..models.entry import Entry -from ..models.travel_diary import TravelDiary -from ..models.photo import Photo # ✨ Importe o modelo Photo +from pilgrim.models.entry import Entry +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo class EntryService: @@ -54,3 +55,24 @@ class EntryService: self.session.commit() return excluded return None + + + def delete_references_for_specific_photo(self, entry: Entry, photo_hash: str) -> Entry: + regex = r"\[\[photo::" + re.escape(photo_hash) + r"\]\]" + entry.text = re.sub(regex, lambda match: ' ' * len(match.group(0)), entry.text) + + self.session.commit() + self.session.refresh(entry) + + return entry + + def delete_all_photo_references(self, entry: Entry, commit=True) -> Entry: + if not entry.photos: + return entry + photo_hashes = {photo.photo_hash[:8] for photo in entry.photos} + regex = r"\[\[photo::(" + "|".join(re.escape(h) for h in photo_hashes) + r")\]\]" + entry.text = re.sub(regex, lambda match: ' ' * len(match.group(0)), entry.text) + if commit: + self.session.commit() + self.session.refresh(entry) + return entry diff --git a/src/pilgrim/service/photo_service.py b/src/pilgrim/service/photo_service.py index f23ae3f..5ee3a18 100644 --- a/src/pilgrim/service/photo_service.py +++ b/src/pilgrim/service/photo_service.py @@ -141,7 +141,7 @@ class PhotoService: return original return None - def delete(self, photo_src: Photo) -> Photo | None: + def delete(self, photo_src: Photo, commit=True) -> Photo | None: excluded = self.read_by_id(photo_src.id) if excluded: # Store photo data before deletion @@ -162,7 +162,8 @@ class PhotoService: file_path.unlink() self.session.delete(excluded) - self.session.commit() + if commit: + self.session.commit() return deleted_photo return None diff --git a/src/pilgrim/service/travel_diary_service.py b/src/pilgrim/service/travel_diary_service.py index 34136e7..eaedcf0 100644 --- a/src/pilgrim/service/travel_diary_service.py +++ b/src/pilgrim/service/travel_diary_service.py @@ -3,11 +3,15 @@ import re import shutil from pathlib import Path +from pilgrim.models.entry import Entry from pilgrim.utils import DirectoryManager from sqlalchemy.exc import IntegrityError -from ..models.travel_diary import TravelDiary +from pilgrim.models.travel_diary import TravelDiary +from unidecode import unidecode +from pilgrim.service.photo_service import PhotoService +from pilgrim.service.entry_service import EntryService class TravelDiaryService: def __init__(self, session): @@ -20,8 +24,10 @@ class TravelDiaryService: - Replaces spaces with underscores - Ensures name is unique by adding a suffix if needed """ + transliterated_name = unidecode(name) + # Remove special characters and replace spaces - safe_name = re.sub(r'[^\w\s-]', '', name) + safe_name = re.sub(r'[^\w\s-]', '', transliterated_name) safe_name = safe_name.strip().replace(' ', '_').lower() # Ensure we have a valid name @@ -145,3 +151,32 @@ class TravelDiaryService: self.session.rollback() raise ValueError(f"Could not delete diary: {str(e)}") return None + + def delete_all_entries(self,travel_diary: TravelDiary): + diary = self.read_by_id(travel_diary.id) + if diary is not None: + diary.entries = [] + self.session.commit() + + + return True + + return False + + def delete_all_photos(self,travel_diary: TravelDiary): + diary = self.read_by_id(travel_diary.id) + photo_service = PhotoService(self.session) + entry_service = EntryService(self.session) + if diary is not None: + + for entry in list(diary.entries): + entry_service.delete_all_photo_references(entry,commit=False) + + for photo in list(diary.photos): + photo_service.delete(photo,commit=False) + + self.session.commit() + + return True + + return False diff --git a/src/pilgrim/ui/screens/diary_list_screen.py b/src/pilgrim/ui/screens/diary_list_screen.py index 649a508..b4912e9 100644 --- a/src/pilgrim/ui/screens/diary_list_screen.py +++ b/src/pilgrim/ui/screens/diary_list_screen.py @@ -9,6 +9,7 @@ from textual.containers import Vertical, Container, Horizontal from pilgrim.models.travel_diary import TravelDiary from pilgrim.ui.screens.about_screen import AboutScreen +from pilgrim.ui.screens.diary_settings_screen import SettingsScreen from pilgrim.ui.screens.edit_diary_modal import EditDiaryModal from pilgrim.ui.screens.new_diary_modal import NewDiaryModal from pilgrim.ui.screens.edit_entry_screen import EditEntryScreen @@ -23,6 +24,7 @@ class DiaryListScreen(Screen): Binding("enter", "open_selected_diary", "Open diary"), Binding("e", "edit_selected_diary", "Edit diary"), Binding("r", "force_refresh", "Force refresh"), + Binding("s", "diary_settings", "Open The Selected Diary Settings"), ] def __init__(self): @@ -285,4 +287,14 @@ class DiaryListScreen(Screen): def action_quit(self): """Action to quit the application""" - self.app.exit() \ No newline at end of file + self.app.exit() + + def action_diary_settings(self): + if self.selected_diary_index is not None: + diary_id = self.diary_id_map.get(self.selected_diary_index) + if diary_id: + self.app.push_screen(SettingsScreen(diary_id=diary_id)) + else: + self.notify("Invalid diary ID") + else: + self.notify("Select a diary to open the settings") diff --git a/src/pilgrim/ui/screens/diary_settings_screen.py b/src/pilgrim/ui/screens/diary_settings_screen.py new file mode 100644 index 0000000..87b418c --- /dev/null +++ b/src/pilgrim/ui/screens/diary_settings_screen.py @@ -0,0 +1,274 @@ + +from textual.widgets import Static +from textual.containers import Container +from textual.widgets import Header, Footer, Label, Button,Checkbox,Input +from textual.screen import Screen +from textual.reactive import reactive +from textual.binding import Binding +from textual import on + +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.ui.screens.modals.delete_all_entries_from_diary_modal import DeleteAllEntriesModal +from pilgrim.ui.screens.modals.delete_all_photos_from_diary_modal import DeleteAllPhotosModal +from pilgrim.ui.screens.modals.delete_diary_modal import DeleteDiaryModal + + +class SettingsScreen(Screen): + is_changed = reactive(False) + BINDINGS = [ + Binding("escape","cancel","Cancel"), + ] + + def __init__(self,diary_id:int): + super().__init__() + self.current_diary = self.app.service_manager.get_travel_diary_service().read_by_id(diary_id) + + self.header = Header() + self.footer = Footer() + self.title = "Settings" + + self.diary_name = Static(self.current_diary.name,id="DiarySettingsScreen-DiaryName") + self.notify(str(self.app.config_manager)) + self.is_the_diary_set_to_auto_open = self.app.config_manager.get_auto_open_diary() == self.current_diary.name + self.diary_entry_count = Static(str(len(self.current_diary.entries))) + self.diary_photo_count = Static(str(len(self.current_diary.photos))) + self.save_button = Button("Save",id="DiarySettingsScreen-SaveButton" ) + self.cancel_button = Button("Cancel",id="DiarySettingsScreen-cancel_button") + self.apply_button = Button("Apply",id="DiarySettingsScreen-ApplyButton") + + self.delete_diary_button = Button("Delete Diary",id="DiarySettingsScreen-DeleteDiaryButton") + self.delete_all_entries_button = Button("Delete All Entries",id="DiarySettingsScreen-DeleteAllEntriesButton") + self.delete_all_photos_button = Button("Delete All Photos",id="DiarySettingsScreen-DeleteAllPhotosButton") + self.set_auto_open_to_this_diary = Checkbox(id="set_auto_open_to_this_diary",value=self.is_the_diary_set_to_auto_open) + self.delete_diary_button_container = Container( + Label("Delete Diary:"), + + self.delete_diary_button, + id="DiarySettingsScreen-DeleteDiaryButtonContainer", + classes="DiarySettingsScreen-DeleteDiaryButtonContainer Button_Container" + ) + self.delete_all_entries_button_container = Container( + Label("Delete All Entries:"), + self.delete_all_entries_button, + + id="DiarySettingsScreen-DeleteAllEntriesButtonContainer", + classes="DiarySettingsScreen-DeleteAllEntriesButtonContainer Button_Container" + ) + self.delete_all_photos_button_container = Container( + Label("Delete All Photos:"), + self.delete_all_photos_button, + + + id="DiarySettingsScreen-DeleteAllPhotosButtonContainer", + classes="DiarySettingsScreen-DeleteAllPhotosButtonContainer Button_Container" + ) + self.diary_name_container = Container( + Label("Diary Name:"), + self.diary_name, + id="DiarySettingsScreen-DiaryNameContainer", + classes="DiarySettingsScreen-DiaryNameContainer Data_Container" + + ) + self.diary_entry_count_container = Container( + Label("Diary Entries:"), + self.diary_entry_count, + id="DiarySettingsScreen-DiaryEntryCountContainer", + classes="DiarySettingsScreen-DiaryEntryCountContainer Data_Container" + ) + self.set_auto_open_to_this_diary_container = Container( + Label("Set Open This Diary On App Start?:"), + self.set_auto_open_to_this_diary, + id="DiarySettingsScreen-SetAutoOpenToThisDiaryContainer", + classes="DiarySettingsScreen-SetAutoOpenToThisDiaryContainer Data_Container" + + ) + self.diary_photo_count_container = Container( + Label("Diary Photos:"), + self.diary_photo_count, + id="DiarySettingsScreen-DiaryPhotoCountContainer", + classes="DiarySettingsScreen-DiaryPhotoCountContainer Data_Container" + ) + + self.diary_info_container = Container( + + self.diary_name_container, + self.diary_entry_count_container, + self.diary_photo_count_container, + self.set_auto_open_to_this_diary_container, + id="DiarySettingsScreen-DiaryInfoContainer", + classes="DiarySettingsScreen-DiaryInfoContainer", + ) + + self.diary_denger_zone_container = Container( + self.backup_diary_button_container, + self.delete_diary_button_container, + self.delete_all_entries_button_container, + self.delete_all_photos_button_container, + id="DiarySettingsScreen-DiaryDengerZoneContainer", + classes="DiarySettingsScreen-DiaryDengerZoneContainer" + ) + self.button_container = Container( + self.save_button, + self.apply_button, + self.cancel_button, + id="DiarySettingsScreen-ButtonContainer", + classes="DiarySettingsScreen-ButtonContainer" + ) + self.main = Container( + self.diary_info_container, + self.diary_denger_zone_container, + self.button_container, + id="DiarySettingsScreen-MainContainer", + classes="DiarySettingsScreen-MainContainer" + ) + self.diary_info_container.border_title = "Diary Info" + self.diary_denger_zone_container.border_title = "Denger Zone" + + @on(Checkbox.Changed, "#set_auto_open_to_this_diary") + def on_checkbox_changed(self, event): + self.is_changed = not self.is_changed + + + @on(Button.Pressed, "#DiarySettingsScreen-cancel_button") + def on_cancel_button_pressed(self, event): + self.action_cancel() + + @on(Button.Pressed, "#DiarySettingsScreen-DeleteDiaryButton") + def on_delete_diary_button_pressed(self, event): + self.app.push_screen(DeleteDiaryModal(diary_id=self.current_diary.id,diary_name=self.current_diary.name)) + + @on(Button.Pressed, "#DiarySettingsScreen-DeleteAllEntriesButton") + def on_delete_all_entries_button_pressed(self, event): + self.app.push_screen(DeleteAllEntriesModal(diary_id=self.current_diary.id)) + + @on(Button.Pressed, "#DiarySettingsScreen-DeleteAllPhotosButton") + def on_delete_all_photos_button_pressed(self, event): + self.app.push_screen(DeleteAllPhotosModal(diary_id=self.current_diary.id)) + + def action_cancel(self): + if self.is_changed: + self.notify("Cancel button pressed, but changes are not saved",severity="error") + return + self.dismiss() + + @on(Button.Pressed, "#DiarySettingsScreen-SaveButton") + def on_save_button_pressed(self, event): + self.action_save() + + @on(Button.Pressed, "#DiarySettingsScreen-ApplyButton") + def on_apply_button_pressed(self, event): + self.action_apply() + + + def watch_is_changed(self, value): + label = self.set_auto_open_to_this_diary_container.query_one(Label) + if value: + label.add_class("DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label") + else: + label.remove_class("DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label") + + def compose(self): + yield Header() + yield self.main + yield Footer() + + def on_mount(self): + if self.app.config_manager.get_auto_open_diary() == self.current_diary.name: + self.call_after_refresh(self.set_checkbox_state) + + def set_checkbox_state(self): + self.set_auto_open_to_this_diary.value = True + + def _set_auto_open_diary(self,value): + + self.app.config_manager.set_auto_open_diary(value) + self.app.config_manager.save_config() + self.is_changed = False + + def _get_auto_open_diary(self): + return self.app.config_manager.get_auto_open_diary() + + def _make_auto_open_diary_value(self): + value = None + if self.set_auto_open_to_this_diary.value: + value = self.current_diary.name + return value + + + def action_save(self): + + if not self.is_changed: + self.dismiss() + return + + value = self._make_auto_open_diary_value() + current_auto_open = self._get_auto_open_diary() + + + if current_auto_open is None: + self._set_auto_open_diary(value) + self.notify("Settings saved") + self.dismiss() + return + + + if current_auto_open == self.current_diary.name: + if value is None: + + self._set_auto_open_diary(None) + self.notify("Auto-open disabled") + else: + + self.is_changed = False + self.notify("No changes made") + self.dismiss() + return + + + if value is not None: + + self._set_auto_open_diary(value) + self.notify(f"Auto-open changed from '{current_auto_open}' to '{self.current_diary.name}'") + self.dismiss() + else: + + self.is_changed = False + self.notify("No changes made") + self.dismiss() + + + def action_apply(self): + + if not self.is_changed: + return + + value = self._make_auto_open_diary_value() + current_auto_open = self._get_auto_open_diary() + + + if current_auto_open is None: + self._set_auto_open_diary(value) + self.notify("Settings applied") + return + + + if current_auto_open == self.current_diary.name: + if value is None: + + self._set_auto_open_diary(None) + self.notify("Auto-open disabled") + else: + + self.is_changed = False + self.notify("No changes made") + return + + + if value is not None: + + self._set_auto_open_diary(value) + self.notify(f"Auto-open changed from '{current_auto_open}' to '{self.current_diary.name}'") + else: + + self.is_changed = False + self.notify("No changes made") \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/delete_all_entries_from_diary_modal.py b/src/pilgrim/ui/screens/modals/delete_all_entries_from_diary_modal.py new file mode 100644 index 0000000..b03db96 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/delete_all_entries_from_diary_modal.py @@ -0,0 +1,33 @@ + +from textual.widgets import Button + +from textual import on + + +from pilgrim.ui.screens.modals.delete_yes_confirmation_modal import DeleteYesConfirmationModal + + +class DeleteAllEntriesModal(DeleteYesConfirmationModal): + def __init__(self,diary_id:int): + super().__init__(diary_id) + self.head_text.update("Are you sure you want to delete all entries from this diary?") + self.delete_button.add_class("DeleteDiaryModal-DeleteButton") + + + + @on(Button.Pressed, ".DeleteDiaryModal-DeleteButton") + def on_delete_button_pressed(self, event): + + from pilgrim.ui.screens.diary_list_screen import DiaryListScreen + + self.result = True + self._delete_entries() + self.dismiss() + self.app.push_screen(DiaryListScreen()) + + def _delete_entries(self): + diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id) + if self.app.service_manager.get_travel_diary_service().delete_all_entries(diary): + self.notify("All entries deleted successfully") + else: + self.notify("Failed to delete all entries") diff --git a/src/pilgrim/ui/screens/modals/delete_all_photos_from_diary_modal.py b/src/pilgrim/ui/screens/modals/delete_all_photos_from_diary_modal.py new file mode 100644 index 0000000..6b8619d --- /dev/null +++ b/src/pilgrim/ui/screens/modals/delete_all_photos_from_diary_modal.py @@ -0,0 +1,32 @@ + +from textual.widgets import Button + +from textual import on + +from pilgrim.ui.screens.modals.delete_yes_confirmation_modal import DeleteYesConfirmationModal + + +class DeleteAllPhotosModal(DeleteYesConfirmationModal): + def __init__(self,diary_id:int): + super().__init__(diary_id) + self.head_text.update("Are you sure you want to delete all photos from this diary?") + self.delete_button.add_class("DeleteDiaryModal-DeleteButton") + + + + @on(Button.Pressed, ".DeleteDiaryModal-DeleteButton") + def on_delete_button_pressed(self, event): + + from pilgrim.ui.screens.diary_list_screen import DiaryListScreen + + self.result = True + self._delete_all_photo() + self.dismiss() + self.app.push_screen(DiaryListScreen()) + + def _delete_all_photo(self): + diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id) + if self.app.service_manager.get_travel_diary_service().delete_all_photos(diary): + self.notify("All photos deleted successfully") + else: + self.notify("Failed to delete all photos") \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/delete_diary_modal.py b/src/pilgrim/ui/screens/modals/delete_diary_modal.py new file mode 100644 index 0000000..8930dfd --- /dev/null +++ b/src/pilgrim/ui/screens/modals/delete_diary_modal.py @@ -0,0 +1,77 @@ +from textual.containers import Container +from textual.widgets import Header, Footer, Label, Button,Input +from textual.screen import Screen +from textual.binding import Binding +from textual import on + + + + +class DeleteDiaryModal(Screen): + + BINDINGS = [ + Binding("escape","cancel","Cancel"), + ] + def __init__(self, diary_id: int,diary_name:str): + super().__init__() + self.diary_id = diary_id + self.diary_name = diary_name + self.user_input = Input(placeholder=f"Type diary name to confirm: ({self.diary_name})",id="DeleteDiaryModal-UserInput") + self.delete_button = Button("Delete Diary",id="DeleteDiaryModal-DeleteButton",disabled=True) + self.cancel_button = Button("Cancel",id="DeleteDiaryModal-CancelButton") + self.result = None + + def compose(self): + yield Header() + yield Container( + Label("Are you sure you want to delete this diary?"), + self.user_input, + Container( + self.delete_button, + self.cancel_button, + id="DeleteDiaryModal-ButtonContainer", + classes="DeleteDiaryModal-ButtonContainer" + ), + id="DeleteDiaryModal-MainContainer", + classes="DeleteDiaryModal-MainContainer" + ) + yield Footer() + + @on(Input.Changed,"#DeleteDiaryModal-UserInput") + def on_user_input_changed(self, event): + input_text = event.value.strip() + + if input_text == self.diary_name: + self.delete_button.disabled = False + else: + self.delete_button.disabled = True + + + @on(Button.Pressed,"#DeleteDiaryModal-DeleteButton") + def on_delete_button_pressed(self, event): + + self.result = True + self._delete_diary() + self.dismiss() + + from pilgrim.ui.screens.diary_list_screen import DiaryListScreen + + self.app.push_screen(DiaryListScreen()) + + + + @on(Button.Pressed,"#DeleteDiaryModal-CancelButton") + def on_cancel_button_pressed(self, event): + self.action_cancel() + + + def action_cancel(self): + self.dismiss() + + def _delete_diary(self): + diary = self.app.service_manager.get_travel_diary_service().read_by_id(self.diary_id) + self.app.service_manager.get_travel_diary_service().delete(diary) + if self.app.config_manager.get_auto_open_diary() == self.diary_name: + self.app.config_manager.set_auto_open_diary(None) + self.app.config_manager.save_config() + diff --git a/src/pilgrim/ui/screens/modals/delete_yes_confirmation_modal.py b/src/pilgrim/ui/screens/modals/delete_yes_confirmation_modal.py new file mode 100644 index 0000000..2e779d0 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/delete_yes_confirmation_modal.py @@ -0,0 +1,57 @@ +from textual.containers import Container +from textual.widgets import Header, Footer, Label, Button,Input +from textual.screen import Screen +from textual.binding import Binding +from textual import on + + + + +class DeleteYesConfirmationModal(Screen): + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + ] + def __init__(self,diary_id:int): + super().__init__() + self.diary_id = diary_id + self.user_input = Input(placeholder="Type 'Yes, I do ' to confirm",id="DeleteYesConfirmationModal-UserInput") + self.delete_button = Button("Delete",id="DeleteYesConfirmationModal-DeleteButton",disabled=True) + self.cancel_button = Button("Cancel",id="DeleteYesConfirmationModal-CancelButton") + self.head_text = Label("Are you sure you want to delete this diary?",id="DeleteYesConfirmationModal-HeadText") + self.second_head_text = Label("This action cannot be undone.",id="DeleteYesConfirmationModal-SecondHeadText") + self.delete_modal_container = Container( + self.head_text, + self.second_head_text, + self.user_input, + Container( + self.delete_button, + self.cancel_button, + id="DeleteYesConfirmationModal-DeleteButtonContainer", + classes="DeleteYesConfirmationModal-DeleteButtonContainer" + ), + id="DeleteYesConfirmationModal-DeleteModalContainer", + classes="DeleteYesConfirmationModal-DeleteModalContainer" + ) + self.result = None + + @on(Input.Changed,"#DeleteYesConfirmationModal-UserInput") + def on_user_input_changed(self, event): + input_text = event.value.strip() + if input_text == "Yes, I do": + self.delete_button.disabled = False + else: + self.delete_button.disabled = True + + @on(Button.Pressed,"#DeleteYesConfirmationModal-CancelButton") + def on_cancel_button_pressed(self, event): + self.action_cancel() + + def action_cancel(self): + from pilgrim.ui.screens.diary_settings_screen import SettingsScreen + self.dismiss() + self.app.push_screen(SettingsScreen(self.diary_id)) + + def compose(self): + yield Header() + yield Footer() + yield self.delete_modal_container \ No newline at end of file diff --git a/src/pilgrim/ui/styles/pilgrim.css b/src/pilgrim/ui/styles/pilgrim.css index 5b9bf3b..8486ac0 100644 --- a/src/pilgrim/ui/styles/pilgrim.css +++ b/src/pilgrim/ui/styles/pilgrim.css @@ -1,7 +1,8 @@ Screen { layout: vertical; - background: $surface-darken-1; + background: $primary-background-darken-3; align: center middle; + hatch: right $secondary-background-darken-3; } .EditEntryScreen-sub-header { @@ -623,4 +624,265 @@ Screen.-modal { .ConfirmDeleteModal-Button { margin: 0 1; width: 1fr; +} + +.DeleteYesConfirmationModal-DeleteModalContainer, +.DeleteDiaryModal-MainContainer { + align: center middle; + layout: vertical; + margin: 2; + padding: 2; + background: $primary-background; + height: auto; + width: auto; + min-width: 80%; + max-width: 95%; + border: solid $primary; +} + +/* Labels de texto */ +.DeleteYesConfirmationModal-DeleteModalContainer > Label, +.DeleteDiaryModal-MainContainer > Label { + margin: 1 0; + padding: 0 1; + color: $error; + text-align: center; + width: 100%; +} + +/* Input fields */ +.DeleteYesConfirmationModal-DeleteModalContainer > Input, +.DeleteDiaryModal-MainContainer > Input { + margin: 1 0; + width: 100%; + height: 3; +} + +/* Container dos botões */ +.DeleteYesConfirmationModal-DeleteButtonContainer, +.DeleteDiaryModal-ButtonContainer { + layout: horizontal; + align: center middle; + margin: 2 0 0 0; + height: auto; + width: 100%; + padding: 0; +} + +/* Botões individuais */ +.DeleteYesConfirmationModal-DeleteButtonContainer > Button, +.DeleteDiaryModal-ButtonContainer > Button { + width: 45%; + margin: 0 1; + height: 3; +} + +/* Botão de delete (primeiro botão) */ +.DeleteYesConfirmationModal-DeleteButtonContainer > Button:first-child, +.DeleteDiaryModal-ButtonContainer > Button:first-child { + background: $error-darken-1; +} + +/* Botão de cancel */ +.DeleteYesConfirmationModal-DeleteButtonContainer > Button:last-child, +.DeleteDiaryModal-ButtonContainer > Button:last-child { + background: $surface; +} + +/* Estados disabled para botões de delete */ +.DeleteYesConfirmationModal-DeleteButtonContainer > Button:first-child:disabled, +.DeleteDiaryModal-ButtonContainer > Button:first-child:disabled { + background: $surface-lighten-1; + color: $text-muted; +} + +/* Espaçamento específico para labels secundários */ +#DeleteYesConfirmationModal-SecondHeadText { + margin: 1 0; + padding: 0 1; + color: $warning; + text-align: center; + text-style: italic; +} + + + + +.Data_Container{ + width: 100%; + layout: grid; + grid-size: 2 1; /* 2 colunas, 1 linha */ + height:auto; + + +} + +.Button_Container{ + width: 100%; + layout: grid; + grid-size: 2 2; + height: auto; + + +} + + +.DiarySettingsScreen-DeleteDiaryButtonContainer{ + grid-size: 2 1; + padding: 0 1; + content-align: center middle; + padding-bottom:1; + +} + +.DiarySettingsScreen-DeleteAllEntriesButtonContainer{ + + margin:0; + padding: 0 1; + height:auto; + padding-bottom:1; + + +} + +.DiarySettingsScreen-DeleteAllPhotosButtonContainer{ + margin:0; + padding: 0 1; + height:auto; + padding-bottom:1; + +} +.DiarySettingsScreen-DeleteAllPhotosButtonContainer > Label, +.DiarySettingsScreen-DeleteAllEntriesButtonContainer > Label{ +color: $error-lighten-3; +padding:1; + +} + +.DiarySettingsScreen-BackupDiaryButtonContainer{ +margin:0; +padding:0 1; +padding-bottom:1 + +} + +.DiarySettingsScreen-BackupDiaryButtonContainer > Label{ +padding:1; +color: $success-darken-1; +} + +.DiarySettingsScreen-BackupDiaryButtonContainer > Button{ + background: $success; +} + +.DiarySettingsScreen-DeleteDiaryButtonContainer > Label{ + color: $error-lighten-3; + content-align: left middle; + padding:1 + + + +} + +.DiarySettingsScreen-DeleteAllPhotosButtonContainer > Button, +.DiarySettingsScreen-DeleteAllEntriesButtonContainer > Button, +.DiarySettingsScreen-DeleteDiaryButtonContainer Button{ + + background: $error; + + } + +.DiarySettingsScreen-MainContainer{ + + align: center top; + layout: vertical; + margin:1; + padding:1; + background: $primary-background + + +} + +.DiarySettingsScreen-DiaryInfoContainer{ + + padding:1 2; + border:round grey; + height:auto; + width: 90%; + padding-bottom: 0; +} + +.DiarySettingsScreen-ButtonContainer{ + height:auto; + layout: grid; + grid-size: 3 1; + grid-gutter:2; + dock:bottom; +} + +.DiarySettingsScreen-ButtonContainer > Button:first-child { + + margin-left:2 + +} + +.DiarySettingsScreen-ButtonContainer > Button:last-child { + + margin-right:2 + +} + +.DiarySettingsScreen-ButtonContainer > Button { + width: 100%; + + + +} + + +#DiarySettingsScreen-DiaryPhotoCountContainer > Static:first-child, +#DiarySettingsScreen-DiaryEntryCountContainer > Static:first-child, +#DiarySettingsScreen-DiaryNameContainer > Static:first-child{ + + text-align: left; + + padding: 0 1; + padding-bottom:1 + +} + + + +#DiarySettingsScreen-DiaryPhotoCountContainer > Static:last-child, +#DiarySettingsScreen-DiaryEntryCountContainer > Static:last-child, +#DiarySettingsScreen-DiaryNameContainer > Static:last-child{ + + text-align: right; + padding:0 1 + +} + +.DiarySettingsScreen-SetAutoOpenToThisDiaryContainer > Checkbox{ + + margin:0; + padding:1; + background: $primary-background; + border:none; + + + +} + +.DiarySettingsScreen-SetAutoOpenToThisDiaryContainer-Not-Saved-Label{ + text-style:bold; + color:$warning-lighten-2; + +} + +.DiarySettingsScreen-DiaryDengerZoneContainer{ + border: round $error-darken-1; + width: 90%; + padding: 0 1; + height: auto; + padding-bottom: 0; } \ No newline at end of file diff --git a/src/pilgrim/utils/config_manager.py b/src/pilgrim/utils/config_manager.py index 7a0f856..29b1832 100644 --- a/src/pilgrim/utils/config_manager.py +++ b/src/pilgrim/utils/config_manager.py @@ -46,6 +46,8 @@ class ConfigManager(metaclass=SingletonMeta): if self.__data["settings"]["diary"]["auto_open_diary_on_startup"] == "": self.auto_open_diary = None + else: + self.auto_open_diary = self.__data["settings"]["diary"]["auto_open_diary_on_startup"] self.auto_open_new_diary = self.__data["settings"]["diary"]["auto_open_on_creation"] else: print("Error: config.toml not found.") @@ -103,5 +105,8 @@ class ConfigManager(metaclass=SingletonMeta): def set_auto_open_diary(self, value: str): self.auto_open_diary = value + def get_auto_open_diary(self): + return self.auto_open_diary + def set_auto_open_new_diary(self, value: bool): self.auto_open_new_diary = value diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..59d0e26 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from pilgrim.database import Base +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.photo import Photo + +# Todos os imports necessários para as fixtures devem estar aqui +# ... + +@pytest.fixture(scope="function") +def db_session(): + """Esta fixture agora está disponível para TODOS os testes.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + Base.metadata.drop_all(engine) + +@pytest.fixture +def populated_db_session(db_session): + """Esta também fica disponível para todos.""" + travel_diary = TravelDiary(name="My Travel Diary", directory_name="viagem-teste") + db_session.add(travel_diary) + db_session.commit() + return db_session + +@pytest.fixture +def session_with_one_diary(db_session): + diary = TravelDiary(name="Diário de Teste", directory_name="diario_de_teste") + db_session.add(diary) + db_session.commit() + db_session.refresh(diary) + return db_session, diary + + +@pytest.fixture +def session_with_photos(session_with_one_diary): + session, diary = session_with_one_diary + + # Usamos a mesma raiz de diretório que o mock do teste espera + diaries_root = "/fake/diaries_root" + + photo1 = Photo( + # CORREÇÃO: O caminho agora inclui a raiz e a subpasta 'images' + filepath=f"{diaries_root}/{diary.directory_name}/images/p1.jpg", + name="Foto 1", + photo_hash="hash1", + fk_travel_diary_id=diary.id + ) + photo2 = Photo( + filepath=f"{diaries_root}/{diary.directory_name}/images/p2.jpg", + name="Foto 2", + photo_hash="hash2", + fk_travel_diary_id=diary.id + ) + + session.add_all([photo1, photo2]) + session.commit() + + return session, [photo1, photo2] \ No newline at end of file diff --git a/tests/service/test_entry_service.py b/tests/service/test_entry_service.py new file mode 100644 index 0000000..6cf267c --- /dev/null +++ b/tests/service/test_entry_service.py @@ -0,0 +1,269 @@ +from re import search + +import pytest +from datetime import datetime +from unittest.mock import Mock +from sqlalchemy import create_engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +from pilgrim.database import Base +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.entry import Entry +from pilgrim.models.photo import Photo + +from pilgrim.service.entry_service import EntryService + + + +@pytest.fixture +def session_with_an_entry(populated_db_session): + session = populated_db_session + initial_entry = Entry( + title="Título Original", + text="Texto original.", + date=datetime(2025, 1, 1), + travel_diary_id=1 + ) + session.add(initial_entry) + session.commit() + return session, initial_entry.id + +@pytest.fixture +def session_with_multiple_entries(populated_db_session): + """Fixture que cria um diário e duas entradas para ele.""" + session = populated_db_session + + entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime(2025, 1, 1), travel_diary_id=1) + entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime(2025, 1, 2), travel_diary_id=1) + + session.add_all([entry1, entry2]) + session.commit() + + return session + +def test_create_entry_successfully(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture + title = "Primeiro Dia na Praia" + text = "O dia foi ensolarado e o mar estava ótimo." + date = datetime(2025, 7, 20) + photos = [Photo(filepath="/path/to/photo1.jpg",name="Photo 1",photo_hash="hash_12345678",fk_travel_diary_id=diary_id), Photo(filepath="/path/to/photo2.jpg",name="Photo 1",photo_hash="hash_87654321",fk_travel_diary_id=diary_id)] + created_entry = service.create( + travel_diary_id=diary_id, + title=title, + text=text, + date=date, + photos=photos + ) + assert created_entry is not None + assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID + assert created_entry.title == title + assert created_entry.text == text + assert len(created_entry.photos) == 2 + assert created_entry.photos[0].filepath == "/path/to/photo1.jpg" + + entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one() + assert entry_in_db.title == "Primeiro Dia na Praia" + +def test_create_entry_fails_when_diary_id_is_invalid(db_session): + session = db_session + service = EntryService(session) + invalid_id = 666 + + result = service.create( + travel_diary_id=invalid_id, + title="Título de Teste", + text="Texto de Teste", + date=datetime(2025, 7, 20), + photos=[] + ) + + assert result is None + +def test_create_entry_successfully_without_photo(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture + title = "Primeiro Dia na Praia" + text = "O dia foi ensolarado e o mar estava ótimo." + date = datetime(2025, 7, 20) + photos = [] + created_entry = service.create( + travel_diary_id=diary_id, + title=title, + text=text, + date=date, + photos=photos + ) + assert created_entry is not None + assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID + assert created_entry.title == title + assert created_entry.text == text + assert len(created_entry.photos) == 0 + entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one() + assert entry_in_db.title == "Primeiro Dia na Praia" + +def test_create_entry_fails_with_null_title(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + with pytest.raises(IntegrityError): + service.create( + travel_diary_id=diary_id, + title=None, + text="Um texto qualquer.", + date=datetime.now(), + photos=[] + ) + +def test_create_entry_fails_with_null_date(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + with pytest.raises(IntegrityError): + service.create( + travel_diary_id=diary_id, + title="Sabado de sol", + text="Um texto qualquer.", + date=None, + photos=[] + ) + +def test_create_entry_fails_with_null_diary_id(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + result = service.create( + travel_diary_id=None, + title="Sabado de sol", + text="Um texto qualquer.", + date=datetime.now(), + photos=[] + ) + assert result is None +def test_ready_by_id_successfully(session_with_an_entry): + session,_ = session_with_an_entry + service = EntryService(session) + search_id = 1 + result = service.read_by_id(search_id) + assert result is not None +def test_ready_by_id_fails_when_id_is_invalid(db_session): + session = db_session + service = EntryService(session) + invalid_id = 666 + result = service.read_by_id(invalid_id) + assert result is None + +def test_read_all_returns_all_entries(session_with_multiple_entries): + session = session_with_multiple_entries + service = EntryService(session) + all_entries = service.read_all() + assert isinstance(all_entries, list) + assert len(all_entries) == 2 + assert all_entries[0].title == "Entrada 1" + assert all_entries[1].title == "Entrada 2" + +def test_read_all_returns_empty_list_on_empty_db(db_session): + session = db_session + service = EntryService(session) + all_entries = service.read_all() + assert isinstance(all_entries, list) + assert len(all_entries) == 0 + +def test_update_entry_successfully(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + new_date = datetime(2025, 1, 2) + entry_dst = Entry( + title="Título Atualizado", + text="Texto atualizado.", + date=new_date, + travel_diary_id=1, # Mantemos o mesmo travel_diary_id + photos=[] + ) + updated_entry = service.update(entry_src, entry_dst) + assert updated_entry is not None + assert updated_entry.id == entry_id + assert updated_entry.title == "Título Atualizado" + assert updated_entry.text == "Texto atualizado." + entry_in_db = session.query(Entry).filter_by(id=entry_id).one() + assert entry_in_db.title == "Título Atualizado" + +def test_update_entry_fails_if_entry_does_not_exist(db_session): + service = EntryService(db_session) + non_existent_entry = Entry( + title="dummy", + text="dummy", + date=datetime.now(), + travel_diary_id=1) + non_existent_entry.id = 999 + entry_with_new_data = Entry(title="Novo Título", text="Novo Texto", date=datetime.now(), travel_diary_id=1) + result = service.update(non_existent_entry, entry_with_new_data) + assert result is None + +def test_update_fails_with_null_title(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=None, + text="Texto atualizado.", + date=datetime.now(), + travel_diary_id=1, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_update_fails_with_null_date(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=entry_src.title, + text="Texto atualizado.", + date=None, + travel_diary_id=1, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_update_fails_with_null_diary_id(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=entry_src.title, + text="Texto atualizado.", + date=datetime.now(), + travel_diary_id=None, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_delete_successfully_removes_entry(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_to_delete = service.read_by_id(entry_id) + assert entry_to_delete is not None + deleted_entry = service.delete(entry_to_delete) + assert deleted_entry is not None + assert deleted_entry.id == entry_id + entry_in_db = service.read_by_id(entry_id) + assert entry_in_db is None + +def test_delete_returns_none_if_entry_does_not_exist(db_session): + service = EntryService(db_session) + non_existent_entry = Entry( + title="dummy", + text="dummy", + date=datetime.now(), + travel_diary_id=1) + non_existent_entry.id = 999 + result = service.delete(non_existent_entry) + assert result is None diff --git a/tests/service/test_photo_service.py b/tests/service/test_photo_service.py new file mode 100644 index 0000000..62009f7 --- /dev/null +++ b/tests/service/test_photo_service.py @@ -0,0 +1,249 @@ +import pytest +from pathlib import Path + +from pilgrim import TravelDiary +from pilgrim.service.photo_service import PhotoService +import hashlib +from unittest.mock import patch +from pilgrim.models.photo import Photo +from pilgrim.utils import DirectoryManager + + +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(PhotoService, 'hash_file', return_value="fake_hash_123") +def test_create_photo_successfully(mock_hash, mock_copy, session_with_one_diary): + session, diary = session_with_one_diary + service = PhotoService(session) + fake_source_path = Path("/path/original/imagem.jpg") + fake_copied_path = Path(f"~/.pilgrim/diaries/{diary.directory_name}/images/imagem.jpg") + mock_copy.return_value = fake_copied_path + new_photo = service.create( + filepath=fake_source_path, + name="Foto da Praia", + travel_diary_id=diary.id, + caption="Pôr do sol") + mock_hash.assert_called_once_with(fake_source_path) + mock_copy.assert_called_once_with(fake_source_path, diary) + assert new_photo is not None + assert new_photo.name == "Foto da Praia" + assert new_photo.photo_hash == "fake_hash_123" + assert new_photo.filepath == str(fake_copied_path) + +def test_hash_file_generates_correct_hash(tmp_path: Path): + original_content_bytes = b"um conteudo de teste para o hash" + file_on_disk = tmp_path / "test.jpg" + file_on_disk.write_bytes(original_content_bytes) + hash_from_file = PhotoService.hash_file(file_on_disk) + expected_hash_func = hashlib.new('sha3_384') + expected_hash_func.update(original_content_bytes) + hash_from_memory = expected_hash_func.hexdigest() + assert hash_from_file == hash_from_memory + +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(PhotoService, 'hash_file', return_value="hash_ja_existente") +def test_create_photo_returns_none_if_hash_exists(mock_hash, mock_copy, session_with_one_diary): + session, diary = session_with_one_diary + existing_photo = Photo( + filepath="/path/existente.jpg", name="Foto Antiga", + photo_hash="hash_ja_existente", fk_travel_diary_id=diary.id + ) + session.add(existing_photo) + session.commit() + + service = PhotoService(session) + new_photo = service.create( + filepath=Path("/path/novo/arquivo.jpg"), + name="Foto Nova", + travel_diary_id=diary.id + ) + assert new_photo is None + mock_copy.assert_not_called() + +def test_read_by_id_successfully(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_find_id = photos[0].id + found_photo = service.read_by_id(photo_to_find_id) + assert found_photo is not None + assert found_photo.id == photo_to_find_id + assert found_photo.name == "Foto 1" + +def test_read_by_id_returns_none_for_invalid_id(db_session): + service = PhotoService(db_session) + result = service.read_by_id(999) + assert result is None + +def test_read_all_returns_all_photos(session_with_photos): + session, _ = session_with_photos + service = PhotoService(session) + all_photos = service.read_all() + + assert isinstance(all_photos, list) + assert len(all_photos) == 2 + assert all_photos[0].name == "Foto 1" + assert all_photos[1].name == "Foto 2" + +def test_read_all_returns_empty_list_for_empty_db(db_session): + service = PhotoService(db_session) + all_photos = service.read_all() + assert isinstance(all_photos, list) + assert len(all_photos) == 0 + +def test_check_photo_by_hash_finds_existing_photo(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + existing_photo = photos[0] + hash_to_find = existing_photo.photo_hash # "hash1" + diary_id = existing_photo.fk_travel_diary_id # 1 + found_photo = service.check_photo_by_hash(hash_to_find, diary_id) + assert found_photo is not None + assert found_photo.id == existing_photo.id + assert found_photo.photo_hash == hash_to_find + +def test_check_photo_by_hash_returns_none_when_not_found(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + existing_hash = photos[0].photo_hash # "hash1" + existing_diary_id = photos[0].fk_travel_diary_id # 1 + result1 = service.check_photo_by_hash("hash_inexistente", existing_diary_id) + assert result1 is None + invalid_diary_id = 999 + result2 = service.check_photo_by_hash(existing_hash, invalid_diary_id) + assert result2 is None + +def test_update_photo_metadata_successfully(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + photo_with_new_data = Photo( + filepath=photo_to_update.filepath, + name="Novo Nome da Foto", + caption="Nova legenda para a foto.", + photo_hash=photo_to_update.photo_hash, # O hash não muda + addition_date=photo_to_update.addition_date, + fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_data) + assert updated_photo is not None + assert updated_photo.name == "Novo Nome da Foto" + assert updated_photo.caption == "Nova legenda para a foto." + assert updated_photo.photo_hash == photo_to_update.photo_hash + photo_in_db = session.query(Photo).get(photo_to_update.id) + assert photo_in_db.name == "Novo Nome da Foto" + + +@patch.object(PhotoService, 'hash_file') +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_update_photo_with_new_file_successfully( + mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos +): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + new_source_path = Path("/path/para/nova_imagem.jpg") + new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg") + + mock_copy.return_value = new_copied_path + mock_exists.return_value = True + mock_hash.return_value = "novo_hash_calculado" + photo_with_new_file = Photo( + filepath=new_source_path, + name=photo_to_update.name, + photo_hash="hash_antigo", + fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_file) + mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary) + mock_unlink.assert_called_once() + mock_hash.assert_called_once_with(new_copied_path) + assert updated_photo.filepath == str(new_copied_path) + assert updated_photo.photo_hash == "novo_hash_calculado" + +def test_update_photo_returns_none_if_photo_does_not_exist(db_session): + service = PhotoService(db_session) + non_existent_photo_src = Photo( + filepath="/fake/path.jpg", name="dummy", + photo_hash="dummy", fk_travel_diary_id=1 + ) + non_existent_photo_src.id = 999 + photo_with_new_data = Photo( + filepath="/fake/new.jpg", name="new dummy", + photo_hash="new_dummy", fk_travel_diary_id=1 + ) + result = service.update(non_existent_photo_src, photo_with_new_data) + assert result is None + + +@patch.object(PhotoService, 'hash_file') +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_update_photo_with_new_file_successfully( + mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos +): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + new_source_path = Path("/path/para/nova_imagem.jpg") + new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg") + mock_copy.return_value = new_copied_path + mock_exists.return_value = True + mock_hash.return_value = "novo_hash_calculado" + photo_with_new_file = Photo( + filepath=new_source_path, name=photo_to_update.name, + photo_hash="hash_antigo", fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_file) + mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary) + mock_unlink.assert_called_once() + mock_hash.assert_called_once_with(new_copied_path) + + assert updated_photo.filepath == str(new_copied_path) + assert updated_photo.photo_hash == "novo_hash_calculado" + +@patch.object(DirectoryManager, 'get_diary_images_directory') +def test_copy_photo_to_diary_handles_name_collision_with_patch(mock_get_images_dir, db_session, tmp_path: Path): + images_dir = tmp_path / "images" + images_dir.mkdir() + mock_get_images_dir.return_value = images_dir + source_file = tmp_path / "foto.jpg" + source_file.touch() + (images_dir / "foto.jpg").touch() + service = PhotoService(db_session) + fake_diary = TravelDiary(name="test",directory_name="fake_diary") + copied_path = service._copy_photo_to_diary(source_file, fake_diary) + assert copied_path.name == "foto_1.jpg" + assert copied_path.exists() + +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_delete_photo_successfully(mock_get_root, mock_exists, mock_unlink, session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_delete = photos[0] + photo_id = photo_to_delete.id + mock_exists.return_value = True + deleted_photo_data = service.delete(photo_to_delete) + mock_unlink.assert_called_once() + assert deleted_photo_data is not None + assert deleted_photo_data.id == photo_id + photo_in_db = service.read_by_id(photo_id) + assert photo_in_db is None + +@patch('pathlib.Path.unlink') +def test_delete_returns_none_for_non_existent_photo(mock_unlink, db_session): + service = PhotoService(db_session) + non_existent_photo = Photo( + filepath="/fake/path.jpg", name="dummy", + photo_hash="dummy_hash", fk_travel_diary_id=1 + ) + non_existent_photo.id = 999 + result = service.delete(non_existent_photo) + assert result is None + mock_unlink.assert_not_called() + diff --git a/tests/service/test_service_manager.py b/tests/service/test_service_manager.py new file mode 100644 index 0000000..ed66e64 --- /dev/null +++ b/tests/service/test_service_manager.py @@ -0,0 +1,33 @@ +from pilgrim.service.servicemanager import ServiceManager +from unittest.mock import patch, MagicMock + +def test_initial_state_is_none(): + manager = ServiceManager() + assert manager.get_session() is None + assert manager.get_entry_service() is None + assert manager.get_photo_service() is None + assert manager.get_travel_diary_service() is None + +@patch('pilgrim.service.servicemanager.TravelDiaryService') +@patch('pilgrim.service.servicemanager.PhotoService') +@patch('pilgrim.service.servicemanager.EntryService') +def test_get_services_instantiates_with_correct_session( + mock_entry_service, mock_photo_service, mock_travel_diary_service +): + manager = ServiceManager() + mock_session = MagicMock() + manager.set_session(mock_session) + + entry_service_instance = manager.get_entry_service() + photo_service_instance = manager.get_photo_service() + travel_diary_service_instance = manager.get_travel_diary_service() + + mock_entry_service.assert_called_once() + mock_photo_service.assert_called_once() + mock_travel_diary_service.assert_called_once() + mock_entry_service.assert_called_once_with(mock_session) + mock_photo_service.assert_called_once_with(mock_session) + mock_travel_diary_service.assert_called_once_with(mock_session) + assert entry_service_instance == mock_entry_service.return_value + assert photo_service_instance == mock_photo_service.return_value + assert travel_diary_service_instance == mock_travel_diary_service.return_value \ No newline at end of file diff --git a/tests/service/test_travel_diary_service.py b/tests/service/test_travel_diary_service.py new file mode 100644 index 0000000..efab0f9 --- /dev/null +++ b/tests/service/test_travel_diary_service.py @@ -0,0 +1,143 @@ +from unittest.mock import patch, MagicMock +from pathlib import Path + +import pytest + +from pilgrim import TravelDiary +from pilgrim.service.travel_diary_service import TravelDiaryService + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@pytest.mark.asyncio # Marca o teste para rodar código assíncrono +async def test_create_diary_successfully(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + new_diary = await service.async_create("Viagem para a Serra") + assert new_diary is not None + assert new_diary.id is not None + assert new_diary.name == "Viagem para a Serra" + assert new_diary.directory_name == "viagem_para_a_serra" + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@patch.object(TravelDiaryService, '_sanitize_directory_name', return_value="nome_existente") +@pytest.mark.asyncio +async def test_create_diary_handles_integrity_error(mock_sanitize, mock_ensure_dir, db_session): + existing_diary = TravelDiary(name="Diário Antigo", directory_name="nome_existente") + db_session.add(existing_diary) + db_session.commit() + service = TravelDiaryService(db_session) + with pytest.raises(ValueError, match="Could not create diary"): + await service.async_create("Qualquer Nome Novo") + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_by_id_successfully(mock_ensure_dir, session_with_one_diary): + session, diary_to_find = session_with_one_diary + service = TravelDiaryService(session) + found_diary = service.read_by_id(diary_to_find.id) + assert found_diary is not None + assert found_diary.id == diary_to_find.id + assert found_diary.name == "Diário de Teste" + mock_ensure_dir.assert_called_once_with(found_diary) + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_by_id_returns_none_for_invalid_id(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + result = service.read_by_id(999) + assert result is None + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_all_returns_all_diaries(mock_ensure_dir, db_session): + d1 = TravelDiary(name="Diário 1", directory_name="d1") + d2 = TravelDiary(name="Diário 2", directory_name="d2") + db_session.add_all([d1, d2]) + db_session.commit() + service = TravelDiaryService(db_session) + diaries = service.read_all() + assert isinstance(diaries, list) + assert len(diaries) == 2 + assert mock_ensure_dir.call_count == 2 + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_all_returns_empty_list_for_empty_db(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + diaries = service.read_all() + assert isinstance(diaries, list) + assert len(diaries) == 0 + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@patch('pathlib.Path.rename') +@patch.object(TravelDiaryService, '_get_diary_directory') +def test_update_diary_successfully(mock_get_dir, mock_path_rename, mock_ensure, session_with_one_diary): + session, diary_to_update = session_with_one_diary + service = TravelDiaryService(session) + old_path = MagicMock(spec=Path) # Um mock que se parece com um objeto Path + old_path.exists.return_value = True # Dizemos que o diretório antigo "existe" + new_path = Path("/fake/path/diario_atualizado") + mock_get_dir.side_effect = [old_path, new_path] + updated_diary = service.update(diary_to_update.id, "Diário Atualizado") + assert updated_diary is not None + assert updated_diary.name == "Diário Atualizado" + assert updated_diary.directory_name == "diario_atualizado" + old_path.rename.assert_called_once_with(new_path) + +def test_update_returns_none_for_invalid_id(db_session): + service = TravelDiaryService(db_session) + result = service.update(travel_diary_id=999, name="Nome Novo") + assert result is None + +@patch.object(TravelDiaryService, '_cleanup_diary_directory') +def test_delete_diary_successfully(mock_cleanup, session_with_one_diary): + session, diary_to_delete = session_with_one_diary + service = TravelDiaryService(session) + result = service.delete(diary_to_delete) + assert result is not None + assert result.id == diary_to_delete.id + mock_cleanup.assert_called_once_with(diary_to_delete) + diary_in_db = service.read_by_id(diary_to_delete.id) + assert diary_in_db is None + +@patch.object(TravelDiaryService, '_cleanup_diary_directory') +def test_delete_returns_none_for_non_existent_diary(mock_cleanup, db_session): + service = TravelDiaryService(db_session) + non_existent_diary = TravelDiary(name="dummy", directory_name="dummy") + non_existent_diary.id = 999 + result = service.delete(non_existent_diary) + assert result is None + mock_cleanup.assert_not_called() + +@patch.object(TravelDiaryService, '_sanitize_directory_name') +def test_update_raises_value_error_on_name_collision(mock_sanitize, db_session): + + d1 = TravelDiary(name="Diário A", directory_name="diario_a") + d2 = TravelDiary(name="Diário B", directory_name="diario_b") + db_session.add_all([d1, d2]) + db_session.commit() + db_session.refresh(d1) + mock_sanitize.return_value = "diario_b" + service = TravelDiaryService(db_session) + with pytest.raises(ValueError, match="Could not update diary"): + service.update(d1.id, "Diário B") + +def test_sanitize_directory_name_formats_string_correctly(db_session): + service = TravelDiaryService(db_session) + name1 = "Minha Primeira Viagem" + assert service._sanitize_directory_name(name1) == "minha_primeira_viagem" + name2 = "Viagem para o #Rio de Janeiro! @2025" + assert service._sanitize_directory_name(name2) == "viagem_para_o_rio_de_janeiro_2025" + name3 = " Mochilão na Europa " + assert service._sanitize_directory_name(name3) == "mochilao_na_europa" + +def test_sanitize_directory_name_handles_uniqueness(db_session): + existing_diary = TravelDiary(name="Viagem para a Praia", directory_name="viagem_para_a_praia") + db_session.add(existing_diary) + db_session.commit() + service = TravelDiaryService(db_session) + new_sanitized_name = service._sanitize_directory_name("Viagem para a Praia") + assert new_sanitized_name == "viagem_para_a_praia_1" + another_diary = TravelDiary(name="Outra Viagem", directory_name="viagem_para_a_praia_1") + db_session.add(another_diary) + db_session.commit() + + third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia") + assert third_sanitized_name == "viagem_para_a_praia_2" \ No newline at end of file diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..c4f1ae0 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,56 @@ +from unittest.mock import patch, MagicMock +from pilgrim.application import Application + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_application_initialization_wires_dependencies( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + mock_config_instance = MockConfigManager.return_value + mock_db_instance = MockDatabase.return_value + mock_session_instance = mock_db_instance.session.return_value + mock_service_manager_instance = MockServiceManager.return_value + app = Application() + MockConfigManager.assert_called_once() + MockDatabase.assert_called_once_with(mock_config_instance) + MockServiceManager.assert_called_once() + MockUIApp.assert_called_once_with(mock_service_manager_instance, mock_config_instance) + mock_config_instance.read_config.assert_called_once() + mock_db_instance.session.assert_called_once() + mock_service_manager_instance.set_session.assert_called_once_with(mock_session_instance) + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_application_run_calls_methods( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + app = Application() + mock_db_instance = app.database + mock_ui_instance = app.ui + app.run() + mock_db_instance.create.assert_called_once() + mock_ui_instance.run.assert_called_once() + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_get_service_manager_creates_and_configures_new_instance( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + app = Application() + mock_db_instance = app.database + fake_session = MagicMock() + mock_db_instance.session.return_value = fake_session + mock_db_instance.reset_mock() + MockServiceManager.reset_mock() + returned_manager = app.get_service_manager() + mock_db_instance.session.assert_called_once() + MockServiceManager.assert_called_once() + returned_manager.set_session.assert_called_once_with(fake_session) + + assert returned_manager is MockServiceManager.return_value \ No newline at end of file diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..c95a85a --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,9 @@ +from unittest.mock import patch +from pilgrim.command import main + +@patch('pilgrim.command.Application') +def test_main_function_runs_application(MockApplication): + mock_app_instance = MockApplication.return_value + main() + MockApplication.assert_called_once() + mock_app_instance.run.assert_called_once() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..a9529df --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import Mock # A ferramenta para criar nosso "dublê" +from pathlib import Path +from sqlalchemy import inspect, Column, Integer, String +from sqlalchemy.orm import Session + +from pilgrim.database import Database,Base + +class MockUser(Base): + __tablename__ = 'mock_users' + id = Column(Integer, primary_key=True) + name = Column(String) + +@pytest.fixture +def db_instance(tmp_path: Path): + fake_db_path = tmp_path / "test_pilgrim.db" + mock_config_manager = Mock() + mock_config_manager.database_url = str(fake_db_path) + db = Database(mock_config_manager) + return db, fake_db_path + +def test_create_database(db_instance): + db, fake_db_path = db_instance + db.create() + assert fake_db_path.exists() + +def test_table_creation(db_instance): + db, _ = db_instance + db.create() + inspector = inspect(db.engine) + assert "mock_users" in inspector.get_table_names() + +def test_session_returned_corectly(db_instance): + db, _ = db_instance + session = db.session() + assert isinstance(session, Session) + session.close() + + + diff --git a/tests/utils/test_config_manager.py b/tests/utils/test_config_manager.py new file mode 100644 index 0000000..511f43e --- /dev/null +++ b/tests/utils/test_config_manager.py @@ -0,0 +1,67 @@ +import pytest +import tomli +from pathlib import Path +from unittest.mock import patch + +from pilgrim.utils.config_manager import ConfigManager, SingletonMeta +from pilgrim.utils.directory_manager import DirectoryManager + +@pytest.fixture +def clean_singleton(): + SingletonMeta._instances = {} + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_create_default_config_if_not_exists_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + manager = ConfigManager() + config_file = tmp_path / "config.toml" + assert not config_file.exists() + manager.read_config() + assert config_file.exists() + assert manager.database_type == "sqlite" + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_read_existing_config_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + custom_config_content = """ + [database] + url = "/custom/path/to/db.sqlite" + type = "custom_sqlite" + [settings.diary] + auto_open_diary_on_startup = "MyCustomDiary" + auto_open_on_creation = true + """ + config_file = tmp_path / "config.toml" + config_file.write_text(custom_config_content) + + manager = ConfigManager() + manager.read_config() + assert manager.database_url == "/custom/path/to/db.sqlite" + assert manager.database_type == "custom_sqlite" + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_save_config_writes_changes_to_file_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + manager = ConfigManager() + manager.read_config() + manager.set_database_url("/novo/caminho.db") + manager.set_auto_open_new_diary(True) + manager.save_config() + config_file = tmp_path / "config.toml" + with open(config_file, "rb") as f: + data = tomli.load(f) + assert data["database"]["url"] == "/novo/caminho.db" + assert data["settings"]["diary"]["auto_open_on_creation"] is True + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_read_config_raises_error_on_malformed_toml(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + invalid_toml_content = """ + [database] + url = /caminho/sem/aspas + """ + config_file = tmp_path / "config.toml" + config_file.write_text(invalid_toml_content) + manager = ConfigManager() + with pytest.raises(ValueError, match="Invalid TOML configuration"): + manager.read_config() \ No newline at end of file diff --git a/tests/utils/test_directory_manager.py b/tests/utils/test_directory_manager.py new file mode 100644 index 0000000..eb3846b --- /dev/null +++ b/tests/utils/test_directory_manager.py @@ -0,0 +1,71 @@ +import shutil +from pathlib import Path +from unittest.mock import patch +import pytest + +from pilgrim.utils.directory_manager import DirectoryManager + +@patch('os.chmod') +@patch('pathlib.Path.home') +def test_get_config_directory_creates_dir_in_fake_home(mock_home, mock_chmod, tmp_path: Path): + mock_home.return_value = tmp_path + + expected_config_dir = tmp_path / ".pilgrim" + assert not expected_config_dir.exists() + result_path = DirectoryManager.get_config_directory() + assert result_path == expected_config_dir + assert expected_config_dir.exists() + mock_chmod.assert_called_once_with(expected_config_dir, 0o700) + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_no_migration(mock_home, mock_copy, tmp_path: Path): + mock_home.return_value = tmp_path + expected_db_path = tmp_path / ".pilgrim" / "database.db" + result_path = DirectoryManager.get_database_path() + assert result_path == expected_db_path + mock_copy.assert_not_called() + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_with_migration(mock_home, mock_copy, tmp_path: Path, monkeypatch): + fake_home_dir = tmp_path / "home" + fake_project_dir = tmp_path / "project" + fake_home_dir.mkdir() + fake_project_dir.mkdir() + + (fake_project_dir / "database.db").touch() + mock_home.return_value = fake_home_dir + monkeypatch.chdir(fake_project_dir) + result_path = DirectoryManager.get_database_path() + expected_db_path = fake_home_dir / ".pilgrim" / "database.db" + assert result_path == expected_db_path + + mock_copy.assert_called_once_with( + Path("database.db"), + expected_db_path + ) + +@patch('os.chmod') +@patch('pathlib.Path.home') +def test_diary_path_methods_construct_correctly(mock_home, mock_chmod, tmp_path: Path): + mock_home.return_value = tmp_path + images_path = DirectoryManager.get_diary_images_directory("minha-viagem") + expected_path = tmp_path / ".pilgrim" / "diaries" / "minha-viagem" / "data" / "images" + assert images_path == expected_path + assert (tmp_path / ".pilgrim" / "diaries").exists() + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_handles_migration_error(mock_home, mock_copy, tmp_path: Path, monkeypatch): + fake_home_dir = tmp_path / "home" + fake_project_dir = tmp_path / "project" + fake_home_dir.mkdir() + fake_project_dir.mkdir() + (fake_project_dir / "database.db").touch() + mock_home.return_value = fake_home_dir + mock_copy.side_effect = shutil.Error("O disco está cheio!") + monkeypatch.chdir(fake_project_dir) + with pytest.raises(RuntimeError, match="Failed to migrate database"): + DirectoryManager.get_database_path() + mock_copy.assert_called_once()