diff --git a/src/core/animation_manager.py b/src/core/animation_manager.py index faa829b..5569c4b 100644 --- a/src/core/animation_manager.py +++ b/src/core/animation_manager.py @@ -131,8 +131,9 @@ def update_frame(self): angle = 5 * math.sin(self.frame_counter * 0.8) painter.rotate(angle) elif self.current_state == "happy": - # Прыжки - painter.translate(0, -abs(15 * math.sin(self.frame_counter * 0.5))) + # Прыжки (пропорционально высоте) + jump_amplitude = size.height() * 0.15 + painter.translate(0, -abs(jump_amplitude * math.sin(self.frame_counter * 0.5))) elif self.current_state == "sleeping": # Глубокое медленное дыхание + наклон scale = 1.0 + 0.05 * math.sin(self.frame_counter * 0.1) @@ -152,7 +153,8 @@ def update_frame(self): elif self.current_state == "eating": # Наклоны головы вперед-назад при еде scale_y = 1.0 + 0.1 * abs(math.sin(self.frame_counter * 0.8)) - painter.translate(0, 10 * (scale_y - 1.0)) + offset_y = size.height() * 0.1 * (scale_y - 1.0) + painter.translate(0, offset_y) painter.scale(1.0, scale_y) elif self.current_state == "thinking": # Наклон + покачивание diff --git a/src/core/input_manager.py b/src/core/input_manager.py index 9ca9d10..e3ca8b5 100644 --- a/src/core/input_manager.py +++ b/src/core/input_manager.py @@ -44,6 +44,8 @@ def stop(self): self.keyboard_listener.stop() class InputManager(QObject): + laser_mode_changed = Signal(bool) + def __init__(self, pet_window, data_store=None): super().__init__() self.window = pet_window @@ -117,6 +119,14 @@ def start(self): self.monitor.start() self.watchdog.start(500) # Проверка каждые 0.5 сек + def stop(self): + if hasattr(self, 'watchdog') and self.watchdog: + self.watchdog.stop() + if hasattr(self, 'monitor') and self.monitor: + self.monitor.stop() + if self.monitor.isRunning(): + self.monitor.wait() + def _update_kps(self): """Обновляет скользящее окно KPS и текущий счетчик нажатий.""" now = time.time() @@ -410,8 +420,9 @@ def handle_mouse(self, x, y): dy_pet = y - center_y dist_sq_pet = dx_pet * dx_pet + dy_pet * dy_pet - # Оптимизация: сравнение квадрата расстояния (порог 60px -> 3600) - if dist_sq_pet < 3600: + # Оптимизация: сравнение квадрата расстояния (радиус динамически масштабируется с размером котика) + radius = max(25.0, self.window.width() * 0.6) + if dist_sq_pet < radius * radius: # Исключаем пассивный фарм (требуем активное поглаживание: активное движение мыши и кулдаун) if self.last_pet_time == 0: self.last_pet_mouse_pos = (x, y) @@ -442,6 +453,7 @@ def handle_mouse(self, x, y): def toggle_laser_mode(self): self.laser_mode = not self.laser_mode + self.laser_mode_changed.emit(self.laser_mode) if self.laser_mode: self.window.animation_manager.play_state("hunting") # Создаем красивый светящийся красный лазерный курсор diff --git a/src/ui/main_window.py b/src/ui/main_window.py index e619905..30e7733 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -55,7 +55,9 @@ def __init__(self, config_manager=None): self.is_dragging = False self.shake_count = 0 self.last_shake_time = 0 - self.original_size = QSize(100, 100) + + pet_size = self.config.get("pet_size") if self.config else 100 + self.original_size = QSize(pet_size, pet_size) # Начальный размер self.resize(self.original_size) @@ -91,6 +93,14 @@ def set_opacity(self, value): """Устанавливает прозрачность окна (0-100)""" self.setWindowOpacity(value / 100.0) + def set_pet_size(self, size_px): + """Устанавливает базовый размер котика (50-250px).""" + if self.config: + self.config.set("pet_size", size_px) + self.original_size = QSize(size_px, size_px) + self.resize(self.original_size) + self.animation_manager.update_size(self.size()) + def set_always_on_top(self, enabled): """Включает или выключает режим 'Поверх всех окон' динамически""" if self.config: @@ -278,6 +288,10 @@ def mouseReleaseEvent(self, event): self.animation_manager.play_state(self.last_state_before_drag) def closeEvent(self, event): + if hasattr(self, 'animation_manager') and self.animation_manager: + self.animation_manager.anim_timer.stop() + if hasattr(self, 'message_hide_timer') and self.message_hide_timer: + self.message_hide_timer.stop() self.closed.emit() super().closeEvent(event) diff --git a/src/ui/settings_dialog.py b/src/ui/settings_dialog.py index 5339824..1584a48 100644 --- a/src/ui/settings_dialog.py +++ b/src/ui/settings_dialog.py @@ -52,6 +52,21 @@ def __init__(self, config, parent=None): self.opacity_slider.valueChanged.connect(lambda v: self.opacity_val_label.setText(f"{v}%")) layout.addWidget(self.opacity_slider) + # Размер питомца + size_header_layout = QHBoxLayout() + size_header_layout.addWidget(QLabel("Размер котика:")) + self.size_val_label = QLabel(f"{self.config.get('pet_size')}px") + self.size_val_label.setStyleSheet("font-weight: bold; color: #555;") + size_header_layout.addStretch() + size_header_layout.addWidget(self.size_val_label) + layout.addLayout(size_header_layout) + + self.size_slider = QSlider(Qt.Horizontal) + self.size_slider.setRange(50, 250) + self.size_slider.setValue(self.config.get("pet_size")) + self.size_slider.valueChanged.connect(lambda v: self.size_val_label.setText(f"{v}px")) + layout.addWidget(self.size_slider) + # Интервал растяжки layout.addWidget(QLabel("Интервал растяжки (мин):")) self.stretch_spin = QSpinBox() @@ -168,6 +183,7 @@ def save_settings(self): self.config.set("always_on_top", self.always_on_top_check.isChecked()) self.config.set("volume", self.volume_slider.value()) self.config.set("opacity", self.opacity_slider.value()) + self.config.set("pet_size", self.size_slider.value()) self.config.set("stretch_interval", self.stretch_spin.value()) self.config.set("pomodoro_work", self.pomodoro_work_spin.value()) self.config.set("pomodoro_break", self.pomodoro_break_spin.value()) diff --git a/src/ui/stats_dialog.py b/src/ui/stats_dialog.py index c5308a4..1dfe404 100644 --- a/src/ui/stats_dialog.py +++ b/src/ui/stats_dialog.py @@ -51,6 +51,11 @@ def __init__(self, db, parent=None): main_layout.addLayout(btn_layout) + def closeEvent(self, event): + if hasattr(self, 'update_timer') and self.update_timer: + self.update_timer.stop() + super().closeEvent(event) + def update_ui(self): self.update_progress_ui() if self.tabs.currentIndex() == 1: diff --git a/src/ui/tray_menu.py b/src/ui/tray_menu.py index b3d0392..aac2a6a 100644 --- a/src/ui/tray_menu.py +++ b/src/ui/tray_menu.py @@ -30,6 +30,10 @@ def __init__(self, pet_window): self.window.timer_system.pomodoro_tick.connect(self.update_pomodoro_status) self.window.timer_system.pomodoro_finished.connect(self.on_pomodoro_finished) + # Подписка на изменение режима лазера + if self.window.input_manager: + self.window.input_manager.laser_mode_changed.connect(self.laser_action.setChecked) + def setup_menu(self): # 1. Секция статуса Pomodoro self.status_action = QAction("Таймер не запущен", self) @@ -59,10 +63,10 @@ def setup_menu(self): self.menu.addSeparator() # Лазерная указка - laser_action = QAction("Лазерная указка 🔴", self) - laser_action.setCheckable(True) - laser_action.triggered.connect(self.toggle_laser) - self.menu.addAction(laser_action) + self.laser_action = QAction("Лазерная указка 🔴", self) + self.laser_action.setCheckable(True) + self.laser_action.triggered.connect(self.toggle_laser) + self.menu.addAction(self.laser_action) self.menu.addSeparator() @@ -183,9 +187,10 @@ def start_break_timer(self, checked=False): def show_settings(self, checked=False): dialog = SettingsDialog(self.window.config, self.window) if dialog.exec(): - # Обновляем скин и прозрачность в реальном времени + # Обновляем скин, прозрачность и размер в реальном времени self.window.animation_manager.set_skin(self.window.config.get("skin")) self.window.set_opacity(self.window.config.get("opacity")) + self.window.set_pet_size(self.window.config.get("pet_size")) # Обновляем меню скинов self.update_skin_menu() # Обновляем режим "Поверх всех окон" в реальном времени diff --git a/src/utils/config_manager.py b/src/utils/config_manager.py index e6ad878..fb39511 100644 --- a/src/utils/config_manager.py +++ b/src/utils/config_manager.py @@ -12,7 +12,8 @@ class ConfigManager: "volume": 70, "language": "ru", "skin": "default", - "opacity": 100 + "opacity": 100, + "pet_size": 100 } def __init__(self, config_path="settings.json"): diff --git a/src/utils/sound_manager.py b/src/utils/sound_manager.py index ccff87d..0575819 100644 --- a/src/utils/sound_manager.py +++ b/src/utils/sound_manager.py @@ -25,3 +25,9 @@ def play_sound(self, sound_name, volume=None): volume = self.config.get("volume") if self.config else 70 effect.setVolume(volume / 100.0) effect.play() + + def clear(self): + for effect in list(self.sounds.values()): + if hasattr(effect, "stop"): + effect.stop() + self.sounds.clear() diff --git a/tests/test_utils.py b/tests/test_utils.py index 1a72e44..8bcc1b7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -29,6 +29,13 @@ def tearDown(self): if os.path.exists(self.db_path): os.remove(self.db_path) + @classmethod + def tearDownClass(cls): + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if app: + app.processEvents() + def test_always_on_top_logic(self): os.environ["QT_QPA_PLATFORM"] = "offscreen" from PySide6.QtWidgets import QApplication @@ -58,6 +65,8 @@ def test_always_on_top_logic(self): self.assertFalse(bool(flags & Qt.WindowStaysOnTopHint)) self.assertFalse(config.get("always_on_top")) + window.close() + def test_config_manager(self): config = ConfigManager(self.config_path) config.set("username", "TestUser") @@ -114,6 +123,7 @@ def test_timer_system_pomodoro(self): # 3. Проверяем остановку ts.stop_pomodoro() + ts.stretch_timer.stop() self.assertEqual(ts.pomodoro_state, "idle") self.assertEqual(ts.pomodoro_remaining, 0) @@ -156,6 +166,7 @@ def test_input_manager_petting_logic(self): im.handle_mouse(190, 190) # движение от (150,150) к (190,190) это ~56px self.assertEqual(im.pending_stats["petting_count"], 1) + im.stop() db.close() def test_periodic_check_time_accumulators(self): @@ -186,6 +197,7 @@ def test_periodic_check_time_accumulators(self): self.assertAlmostEqual(im.points_time_accumulator, 0.5, places=1) self.assertAlmostEqual(im.work_time_accumulator, 0.5, places=1) + im.stop() db.close() def test_laser_mode_transitions(self): @@ -201,19 +213,58 @@ def test_laser_mode_transitions(self): db = DataStore(self.db_path) im = InputManager(mock_window, db) + signal_emitted = [] + im.laser_mode_changed.connect(lambda active: signal_emitted.append(active)) + # Переключаем лазер в True im.toggle_laser_mode() self.assertTrue(im.laser_mode) + self.assertEqual(signal_emitted, [True]) mock_window.animation_manager.play_state.assert_called_with("hunting") mock_window.setCursor.assert_called() # Переключаем обратно в False im.toggle_laser_mode() self.assertFalse(im.laser_mode) + self.assertEqual(signal_emitted, [True, False]) mock_window.animation_manager.play_state.assert_called_with("idle") + im.stop() db.close() + def test_pet_size_configuration_and_window_resize(self): + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QSize + from src.ui.settings_dialog import SettingsDialog + + app = QApplication.instance() or QApplication([]) + + config = ConfigManager(self.config_path) + + # 1. Проверяем значение по умолчанию + self.assertEqual(config.get("pet_size"), 100) + + # 2. Инициализация PetWindow + window = PetWindow(config) + self.assertEqual(window.original_size, QSize(100, 100)) + + # 3. Изменение размера через set_pet_size + window.set_pet_size(150) + self.assertEqual(window.original_size, QSize(150, 150)) + self.assertEqual(config.get("pet_size"), 150) + + # 4. Проверяем регулятор в SettingsDialog + dialog = SettingsDialog(config) + self.assertEqual(dialog.size_slider.value(), 150) + dialog.size_slider.setValue(200) + dialog.save_settings() + + self.assertEqual(config.get("pet_size"), 200) + + dialog.close() + window.close() + def test_force_state_delays_idle(self): mock_window = MagicMock() mock_window.animation_manager.current_state = "idle" @@ -233,6 +284,7 @@ def test_force_state_delays_idle(self): self.assertEqual(im.forced_state_name, "eating") self.assertTrue(im.forced_state_expires > now + 4.5) + im.stop() db.close() def test_force_state_expiry_and_resets(self): @@ -260,6 +312,7 @@ def test_force_state_expiry_and_resets(self): self.assertIsNone(im.forced_state_name) mock_window.animation_manager.play_state.assert_any_call("idle") + im.stop() db.close() def test_sound_manager_with_none_config(self): @@ -267,6 +320,7 @@ def test_sound_manager_with_none_config(self): sm = SoundManager(None) # Test that play_sound on a non-existent sound returns gracefully and doesn't crash on None config sm.play_sound("non_existent_sound_123") + sm.clear() def test_custom_skins_integration(self): # 1. Запись тестового SVG-файла @@ -306,6 +360,9 @@ def test_custom_skins_integration(self): self.assertEqual(anim_mgr.skin, "custom_test_skin") self.assertTrue(anim_mgr.current_anim_path.endswith("cat_custom_test_skin.svg")) + anim_mgr.anim_timer.stop() + label.close() + # Очистка if os.path.exists(test_svg_path): os.remove(test_svg_path) @@ -357,6 +414,7 @@ def test_delete_custom_skin(self): self.assertEqual(config.get("skin"), "default") self.assertNotIn("custom_todel_skin", config.get("custom_skins") or {}) finally: + dialog.close() # Восстанавливаем моки гарантированно QMessageBox.question = original_question QMessageBox.information = original_information @@ -381,6 +439,7 @@ def test_sound_manager_volume_override(self): # Проверка воспроизведения с переопределенной громкостью sm.play_sound("test_meow", volume=80) mock_effect.setVolume.assert_called_with(0.8) + sm.clear() def test_animation_manager_current_fps(self): os.environ["QT_QPA_PLATFORM"] = "offscreen" @@ -412,6 +471,9 @@ def test_animation_manager_current_fps(self): am.current_fps = 15 self.assertEqual(am.current_fps, 15) + am.anim_timer.stop() + label.close() + def test_sound_manager_fallback(self): from src.utils.sound_manager import SoundManager from PySide6.QtMultimedia import QSoundEffect @@ -427,6 +489,7 @@ def test_sound_manager_fallback(self): # Ожидаем, что сработает резервный meow sm.play_sound("happy") mock_effect.play.assert_called() + sm.clear() def test_data_store_reset_logic(self): db = DataStore(self.db_path) @@ -479,6 +542,7 @@ def test_input_manager_reset_logic(self): # Проверяем сброс в БД self.assertEqual(db.get_affection_points(), 0) + im.stop() db.close() def test_stats_dialog_reset_ui_flow(self): @@ -512,6 +576,7 @@ def test_stats_dialog_reset_ui_flow(self): self.assertEqual(db.get_affection_points(), 0) QMessageBox.information.assert_called_once() finally: + dialog.close() QMessageBox.question = original_question QMessageBox.information = original_information db.close() diff --git a/verification/screenshots/stats_history.png b/verification/screenshots/stats_history.png index 1fa59b4..99e8d62 100644 Binary files a/verification/screenshots/stats_history.png and b/verification/screenshots/stats_history.png differ