diff --git a/.copilotignore b/.copilotignore index 32b6ede..3eb7cee 100644 --- a/.copilotignore +++ b/.copilotignore @@ -117,6 +117,11 @@ C/misc/randomJPG/ # Files with many embedded image references python_pkg/cinema_planner/pasted_content.txt +# Large data files (exceed indexing limits) +python_pkg/keyboard_coop/words_dictionary.json +linux_configuration/scripts/digital_wellbeing/pacman/words.txt +python_pkg/word_frequency/test_texts/ + # C build artifacts **/*.out **/random_engine diff --git a/.gitignore b/.gitignore index 6383d12..8133258 100644 --- a/.gitignore +++ b/.gitignore @@ -102,6 +102,9 @@ Thumbs.db *.sqlite *.sqlite3 # Compiled +*.o +*.so +*.a *.exe *.dll *.dylib @@ -110,7 +113,16 @@ Thumbs.db !pomodoro_app/android/app/src/main/res/mipmap-*/ic_launcher.png !pomodoro_app/packaging/arch/pomodoro-app.svg !TS/battery-status/public/favicon.svg -!horatio/horatio_app/assets/demo_recordings/*.wav + +# Binary asset symlinks (point to ../testsAndMisc_binaries/) +pomodoro_app/assets/sounds/long_break_done.wav +pomodoro_app/assets/sounds/long_break_start.wav +pomodoro_app/assets/sounds/short_break_done.wav +pomodoro_app/assets/sounds/work_done.wav +horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav +horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav +horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav +horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav # =========================================================================== diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 120a545..3c21546 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,10 @@ # ============================================================================== # Pre-commit Configuration - Multi-language Linting & Formatting # ============================================================================== -# Install: pre-commit install -# Run all: pre-commit run --all-files -# Update hooks: pre-commit autoupdate +# Install: pre-commit install && pre-commit install --hook-type pre-push +# Fast lint: pre-commit run --all-files (linters only, ~10 s) +# Full suite: pre-commit run --all-files --hook-stage pre-push (+ tests) +# Update hooks: pre-commit autoupdate # ============================================================================== # Global settings @@ -101,12 +102,13 @@ repos: types_or: [python, pyi] # =========================================================================== - # MYPY - Static type checking (minimal for scripts repository) + # MYPY - Static type checking (runs on push only for speed) # =========================================================================== - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 hooks: - id: mypy + stages: [pre-push] args: - --ignore-missing-imports - --no-error-summary @@ -144,12 +146,13 @@ repos: - types-python-dateutil # =========================================================================== - # PYLINT - Comprehensive Python linter + # PYLINT - Comprehensive Python linter (runs on push only for speed) # =========================================================================== - repo: https://github.com/pylint-dev/pylint rev: v3.3.2 hooks: - id: pylint + stages: [pre-push] args: - --rcfile=pyproject.toml - --fail-under=8.0 @@ -162,12 +165,13 @@ repos: exclude: ^(Bash/|\.venv/) # =========================================================================== - # BANDIT - Security linter (relaxed for scripts) + # BANDIT - Security linter (runs on push only for speed) # =========================================================================== - repo: https://github.com/PyCQA/bandit rev: 1.7.10 hooks: - id: bandit + stages: [pre-push] args: - -c - pyproject.toml @@ -180,6 +184,7 @@ repos: # =========================================================================== # PYTEST + COVERAGE - Run tests and enforce 100% code coverage # Only tests for subpackages with changed files are run (see script). + # Runs on push only (slow); use --hook-stage pre-push to run manually. # =========================================================================== - repo: local hooks: @@ -189,6 +194,7 @@ repos: language: system types: [python] pass_filenames: true + stages: [pre-push] # =========================================================================== # VULTURE - Dead code detection (disabled - doesn't work well with pre-commit) @@ -259,17 +265,18 @@ repos: # =========================================================================== # AUTOFLAKE - Remove unused imports/variables + # Disabled: fully redundant with ruff (F401, F841, F811) + --fix # =========================================================================== - - repo: https://github.com/PyCQA/autoflake - rev: v2.3.1 - hooks: - - id: autoflake - args: - - --in-place - - --remove-all-unused-imports - - --remove-unused-variables - - --remove-duplicate-keys - - --expand-star-imports + # - repo: https://github.com/PyCQA/autoflake + # rev: v2.3.1 + # hooks: + # - id: autoflake + # args: + # - --in-place + # - --remove-all-unused-imports + # - --remove-unused-variables + # - --remove-duplicate-keys + # - --expand-star-imports # =========================================================================== # SAFETY - Check for security vulnerabilities in dependencies @@ -291,7 +298,7 @@ repos: # - id: pyright # =========================================================================== - # CHECK JSON/YAML/TOML formatting + # CHECK JSON/YAML/TOML formatting (runs on push only — slow Node.js startup) # =========================================================================== - repo: https://github.com/pre-commit/mirrors-prettier rev: v4.0.0-alpha.8 @@ -299,15 +306,19 @@ repos: - id: prettier types_or: [yaml, json, markdown] exclude: ^(Bash/|\.venv/|.*\.lock$|C/compile_commands\.json) + stages: [pre-push] # =========================================================================== # SHELLCHECK - Shell script linting + # Wrapper batches files to avoid OOM on large repos. # =========================================================================== - - repo: https://github.com/shellcheck-py/shellcheck-py - rev: v0.10.0.1 + - repo: local hooks: - id: shellcheck - args: [--severity=warning] + name: shellcheck + entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- + language: system + types: [shell] exclude: ^pomodoro_app/ # =========================================================================== @@ -427,7 +438,7 @@ repos: # stages: [push] # =========================================================================== - # POMODORO APP - Flutter analyze + test + # POMODORO APP - Flutter analyze + test (push only) # =========================================================================== - repo: local hooks: @@ -437,9 +448,10 @@ repos: language: system files: ^pomodoro_app/ pass_filenames: false + stages: [pre-push] # =========================================================================== - # HORATIO - Dart/Flutter tests with coverage enforcement + # HORATIO - Dart/Flutter tests with coverage enforcement (push only) # =========================================================================== - repo: local hooks: @@ -448,4 +460,5 @@ repos: entry: bash -c 'cd horatio && bash run.sh test' language: system files: ^horatio/ + stages: [pre-push] pass_filenames: false diff --git a/C/imageViewer/.clang-tidy b/C/imageViewer/.clang-tidy index e89e1ea..feeaac8 100644 --- a/C/imageViewer/.clang-tidy +++ b/C/imageViewer/.clang-tidy @@ -16,7 +16,7 @@ Checks: > -readability-isolate-declaration, -clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling -WarningsAsErrors: '' +WarningsAsErrors: "" HeaderFilterRegex: '.*\.h$' AnalyzeTemporaryDtors: false FormatStyle: file diff --git a/C/imageViewer/.vscode/settings.json b/C/imageViewer/.vscode/settings.json index 631ffc4..5abeb68 100644 --- a/C/imageViewer/.vscode/settings.json +++ b/C/imageViewer/.vscode/settings.json @@ -1,43 +1,35 @@ { - "C_Cpp.default.cStandard": "c99", - "C_Cpp.default.intelliSenseMode": "linux-gcc-x64", - "C_Cpp.default.includePath": [ - "${workspaceFolder}/**", - "/usr/include/SDL2" - ], - "C_Cpp.default.defines": [ - "_REENTRANT" - ], - "C_Cpp.default.compilerPath": "/usr/bin/gcc", - "C_Cpp.clang_format_style": "file", - "C_Cpp.clang_format_fallbackStyle": "LLVM", + "C_Cpp.default.cStandard": "c99", + "C_Cpp.default.intelliSenseMode": "linux-gcc-x64", + "C_Cpp.default.includePath": ["${workspaceFolder}/**", "/usr/include/SDL2"], + "C_Cpp.default.defines": ["_REENTRANT"], + "C_Cpp.default.compilerPath": "/usr/bin/gcc", + "C_Cpp.clang_format_style": "file", + "C_Cpp.clang_format_fallbackStyle": "LLVM", - "files.associations": { - "*.h": "c", - "*.c": "c" - }, + "files.associations": { + "*.h": "c", + "*.c": "c" + }, - "editor.formatOnSave": true, - "editor.tabSize": 4, - "editor.insertSpaces": true, - "editor.rulers": [100], + "editor.formatOnSave": true, + "editor.tabSize": 4, + "editor.insertSpaces": true, + "editor.rulers": [100], - "clang-tidy.executable": "clang-tidy", - "clang-tidy.checks": [ - "clang-diagnostic-*", - "clang-analyzer-*", - "bugprone-*", - "cert-*", - "misc-*", - "performance-*", - "portability-*", - "readability-*" - ], + "clang-tidy.executable": "clang-tidy", + "clang-tidy.checks": [ + "clang-diagnostic-*", + "clang-analyzer-*", + "bugprone-*", + "cert-*", + "misc-*", + "performance-*", + "portability-*", + "readability-*" + ], - "cppcheck.enable": true, - "cppcheck.standard": ["c99"], - "cppcheck.suppress": [ - "missingIncludeSystem", - "unusedFunction" - ] + "cppcheck.enable": true, + "cppcheck.standard": ["c99"], + "cppcheck.suppress": ["missingIncludeSystem", "unusedFunction"] } diff --git a/C/imageViewer/LINTING.md b/C/imageViewer/LINTING.md index 7b6ab07..81fc914 100644 --- a/C/imageViewer/LINTING.md +++ b/C/imageViewer/LINTING.md @@ -5,26 +5,31 @@ This directory contains a comprehensive linting setup for the imageViewer C proj ## Tools Included ### 1. **clang-tidy** - Static Analysis + - Configuration: `.clang-tidy` - Checks for bugs, performance issues, and style violations - Enforces modern C coding standards -### 2. **clang-format** - Code Formatting +### 2. **clang-format** - Code Formatting + - Configuration: `.clang-format` - Automatically formats code to consistent style - 100-character line limit, 4-space indentation ### 3. **cppcheck** - Additional Static Analysis + - Detects memory leaks, null pointer dereferences - Checks for undefined behavior ### 4. **gcc with warnings** - Compiler Analysis + - Comprehensive warning flags - Standards compliance checking ## Usage ### Quick Start + ```bash # Install dependencies (Arch Linux) make deps-arch @@ -43,6 +48,7 @@ make memcheck ``` ### Individual Commands + ```bash # Manual linting ./lint.sh @@ -60,12 +66,14 @@ cppcheck --enable=all main.c ## VS Code Integration The `.vscode/settings.json` file provides: + - Automatic formatting on save - C99 standard compliance - IntelliSense configuration for SDL2 - Integrated linting with clang-tidy and cppcheck ## Recommended Extensions for VS Code + - C/C++ (Microsoft) - clang-tidy (mine-cetinkaya-fianso) - cppcheck (unixwrapped) @@ -73,16 +81,18 @@ The `.vscode/settings.json` file provides: ## Linting Rules ### Enabled Checks -- **clang-diagnostic-***: Compiler diagnostics -- **clang-analyzer-***: Static analysis -- **bugprone-***: Bug-prone patterns -- **cert-***: CERT secure coding standards -- **misc-***: Miscellaneous checks -- **performance-***: Performance improvements -- **portability-***: Cross-platform issues -- **readability-***: Code readability + +- **clang-diagnostic-\***: Compiler diagnostics +- **clang-analyzer-\***: Static analysis +- **bugprone-\***: Bug-prone patterns +- **cert-\***: CERT secure coding standards +- **misc-\***: Miscellaneous checks +- **performance-\***: Performance improvements +- **portability-\***: Cross-platform issues +- **readability-\***: Code readability ### Disabled Checks + - `readability-magic-numbers`: Allows constants like window dimensions - `cert-err33-c`: Allows ignoring some function return values - `misc-unused-parameters`: Common in callback functions @@ -107,16 +117,19 @@ The `.vscode/settings.json` file provides: ## Installation on Different Systems ### Arch Linux + ```bash sudo pacman -S clang cppcheck valgrind ``` ### Ubuntu/Debian + ```bash sudo apt install clang-tidy cppcheck clang-format valgrind ``` ### Fedora/RHEL + ```bash sudo dnf install clang-tools-extra cppcheck clang valgrind ``` diff --git a/C/imageViewer/README.md b/C/imageViewer/README.md index ab3a29b..006e461 100644 --- a/C/imageViewer/README.md +++ b/C/imageViewer/README.md @@ -16,22 +16,26 @@ A simple, lightweight image viewer written in C using SDL2. Supports JPG, PNG, B The image viewer requires SDL2 and SDL2_image libraries. ### Ubuntu/Debian + ```bash sudo apt-get update sudo apt-get install libsdl2-dev libsdl2-image-dev ``` ### Fedora/RHEL/CentOS + ```bash sudo dnf install SDL2-devel SDL2_image-devel ``` ### Arch Linux + ```bash sudo pacman -S sdl2 sdl2_image ``` ### macOS (with Homebrew) + ```bash brew install sdl2 sdl2_image ``` @@ -39,6 +43,7 @@ brew install sdl2 sdl2_image ## Installation ### Arch Linux (Automated) + For Arch Linux users, there's an automated installation script that handles everything: ```bash @@ -46,6 +51,7 @@ For Arch Linux users, there's an automated installation script that handles ever ``` This script will: + - Install SDL2 dependencies via pacman - Build the imageviewer from source - Install the binary to `/usr/local/bin` @@ -53,6 +59,7 @@ This script will: - Set up file associations To uninstall: + ```bash ./uninstall_arch.sh ``` @@ -61,14 +68,16 @@ To uninstall: 1. Install dependencies (see above) 2. Build the project: + ```bash make ``` Or use the dependency helper: + ```bash make deps-debian # For Ubuntu/Debian -make deps-fedora # For Fedora/RHEL/CentOS +make deps-fedora # For Fedora/RHEL/CentOS make deps-arch # For Arch Linux make ``` @@ -80,6 +89,7 @@ make ``` Example: + ```bash ./imageviewer photo.jpg ./imageviewer ../misc/randomJPG/14k/bloated_image_1.jpg @@ -87,35 +97,39 @@ Example: ## Controls -| Control | Action | -|---------|--------| -| **Mouse wheel** | Zoom in/out | -| **+ / -** | Zoom in/out (keyboard) | -| **Mouse drag** | Pan around the image | -| **R** | Reset zoom and position to default | -| **F** | Fit image to window | -| **H** | Show help in console | -| **ESC / Q** | Quit the application | +| Control | Action | +| --------------- | ---------------------------------- | +| **Mouse wheel** | Zoom in/out | +| **+ / -** | Zoom in/out (keyboard) | +| **Mouse drag** | Pan around the image | +| **R** | Reset zoom and position to default | +| **F** | Fit image to window | +| **H** | Show help in console | +| **ESC / Q** | Quit the application | ## Features in Detail ### Zooming + - Use mouse wheel to zoom in/out at the mouse cursor position - Keyboard shortcuts: `+` to zoom in, `-` to zoom out - Zoom range: 0.1x to 10x - Smart zoom behavior focuses on mouse position ### Panning + - Click and drag with left mouse button to move around zoomed images - Smooth panning for precise positioning - Works at any zoom level ### Auto-fit + - Large images are automatically scaled to fit the window when first loaded - Press `F` to manually fit the current image to window - Maintains aspect ratio ### Window Management + - Resizable window that adapts to content - Real-time rendering updates - Dark background for better image contrast @@ -141,7 +155,9 @@ Example: ## Troubleshooting ### "SDL could not initialize" Error + Make sure SDL2 development libraries are installed: + ```bash # Ubuntu/Debian sudo apt-get install libsdl2-dev libsdl2-image-dev @@ -151,11 +167,13 @@ pkg-config --libs sdl2 ``` ### "Unable to load image" Error + - Check that the image file exists and is readable - Verify the image format is supported (JPG, PNG, BMP, GIF, TIF) - Try with a different image file to isolate the issue ### Compilation Errors + - Ensure you have a C compiler installed (gcc or clang) - Check that SDL2 headers are available - Try rebuilding with `make clean && make` diff --git a/C/imageViewer/SECURITY.md b/C/imageViewer/SECURITY.md index 2de4a61..2f16e14 100644 --- a/C/imageViewer/SECURITY.md +++ b/C/imageViewer/SECURITY.md @@ -7,6 +7,7 @@ The imageviewer project uses secure coding practices with proper bounds checking ### Why These Warnings Appear The static analyzer flags standard C library functions like: + - `memcpy()` - suggests using `memcpy_s()` - `snprintf()` - suggests using `snprintf_s()` - `strncpy()` - suggests using `strncpy_s()` @@ -34,9 +35,10 @@ if (ret < 0 || ret >= sizeof(full_path)) { } ``` -### Microsoft-Specific _s Functions +### Microsoft-Specific \_s Functions The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are: + - Microsoft-specific extensions - Not part of standard C - Not portable to Linux/Unix systems @@ -47,6 +49,7 @@ The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are: **Status**: ✅ **SECURE** The current implementation is secure because: + - All buffer operations are bounds-checked - No user input is directly copied without validation - File paths are validated for maximum length @@ -63,6 +66,7 @@ For development, these specific warnings can be suppressed since the code has be ``` Or use NOLINT comments for specific lines: + ```c memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling) ``` @@ -70,6 +74,7 @@ memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.Deprecated ### Verification To verify security: + 1. ✅ All string operations use explicit length checking 2. ✅ Buffer overflow conditions are detected and handled 3. ✅ No direct user input to buffer operations diff --git a/C/opening_learner/chess.o b/C/opening_learner/chess.o deleted file mode 100644 index bebc2ad..0000000 Binary files a/C/opening_learner/chess.o and /dev/null differ diff --git a/C/opening_learner/engine.o b/C/opening_learner/engine.o deleted file mode 100644 index 60b6546..0000000 Binary files a/C/opening_learner/engine.o and /dev/null differ diff --git a/C/opening_learner/gui.o b/C/opening_learner/gui.o deleted file mode 100644 index 6b95491..0000000 Binary files a/C/opening_learner/gui.o and /dev/null differ diff --git a/C/opening_learner/main.o b/C/opening_learner/main.o deleted file mode 100644 index c308086..0000000 Binary files a/C/opening_learner/main.o and /dev/null differ diff --git a/C/opening_learner/mistakes.o b/C/opening_learner/mistakes.o deleted file mode 100644 index 91cbd2e..0000000 Binary files a/C/opening_learner/mistakes.o and /dev/null differ diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav deleted file mode 120000 index c140606..0000000 --- a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav +++ /dev/null @@ -1 +0,0 @@ -../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav deleted file mode 120000 index 75a8d9d..0000000 --- a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav +++ /dev/null @@ -1 +0,0 @@ -../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav deleted file mode 120000 index 8a55c82..0000000 --- a/horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav +++ /dev/null @@ -1 +0,0 @@ -../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav \ No newline at end of file diff --git a/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav b/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav deleted file mode 120000 index 4cc4e8f..0000000 --- a/horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav +++ /dev/null @@ -1 +0,0 @@ -../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav \ No newline at end of file diff --git a/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_fw.cpython-313.pyc b/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_fw.cpython-313.pyc deleted file mode 100644 index 7f94d78..0000000 Binary files a/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_fw.cpython-313.pyc and /dev/null differ diff --git a/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_helpers.cpython-313.pyc b/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_helpers.cpython-313.pyc deleted file mode 100644 index a10cc41..0000000 Binary files a/linux_configuration/scripts/misc/testsAndMisc-bash/tools/__pycache__/transcribe_helpers.cpython-313.pyc and /dev/null differ diff --git a/linux_configuration/scripts/utils/gif_to_square.sh b/linux_configuration/scripts/utils/gif_to_square.sh index 881879a..9cd99e2 100755 --- a/linux_configuration/scripts/utils/gif_to_square.sh +++ b/linux_configuration/scripts/utils/gif_to_square.sh @@ -66,7 +66,8 @@ fi INPUT_GIF="$1" OUTPUT_GIF="${2:-}" -BACKGROUND="${3:-${DEFAULT_BG}}" +BACKGROUND="${3:-${DEFAULT_BG}}" # Reserved for future padding mode +export BACKGROUND # Validate input file exists if [[ ! -f ${INPUT_GIF} ]]; then diff --git a/linux_configuration/scripts/utils/upgrade.sh b/linux_configuration/scripts/utils/upgrade.sh index cf42512..0a0fba9 100755 --- a/linux_configuration/scripts/utils/upgrade.sh +++ b/linux_configuration/scripts/utils/upgrade.sh @@ -13,7 +13,6 @@ log() { printf '[upgrade] %s\n' "$*"; } # ===================================================================== fix_duplicate_chrome_edge_repo() { local edge="/etc/apt/sources.list.d/microsoft-edge.list" - local chrome="/etc/apt/sources.list.d/google-chrome.list" if [[ ! -f $edge ]]; then return @@ -57,7 +56,6 @@ fix_wine_legacy_key() { local legacy_keyring="/etc/apt/trusted.gpg" local wine_key_id="D43F640145369C51D786DDEA76F1A20FF987672F" local modern_keyring="/usr/share/keyrings/winehq-archive.gpg" - local wine_source="/etc/apt/sources.list.d/winehq-focal.list" # Check if wine key is in the legacy keyring if ! gpg --no-default-keyring --keyring "$legacy_keyring" --list-keys "$wine_key_id" >/dev/null 2>&1; then diff --git a/pomodoro_app/assets/sounds/long_break_done.wav b/pomodoro_app/assets/sounds/long_break_done.wav deleted file mode 100644 index 0ff28b4..0000000 Binary files a/pomodoro_app/assets/sounds/long_break_done.wav and /dev/null differ diff --git a/pomodoro_app/assets/sounds/long_break_start.wav b/pomodoro_app/assets/sounds/long_break_start.wav deleted file mode 100644 index 9b5fba1..0000000 Binary files a/pomodoro_app/assets/sounds/long_break_start.wav and /dev/null differ diff --git a/pomodoro_app/assets/sounds/short_break_done.wav b/pomodoro_app/assets/sounds/short_break_done.wav deleted file mode 100644 index cce2f11..0000000 Binary files a/pomodoro_app/assets/sounds/short_break_done.wav and /dev/null differ diff --git a/pomodoro_app/assets/sounds/work_done.wav b/pomodoro_app/assets/sounds/work_done.wav deleted file mode 100644 index 972c71b..0000000 Binary files a/pomodoro_app/assets/sounds/work_done.wav and /dev/null differ diff --git a/python_pkg/moviepy_showcase/tests/conftest.py b/python_pkg/moviepy_showcase/tests/conftest.py index 6dc920f..fe80036 100644 --- a/python_pkg/moviepy_showcase/tests/conftest.py +++ b/python_pkg/moviepy_showcase/tests/conftest.py @@ -6,6 +6,7 @@ so source modules can be imported without moviepy installed. from __future__ import annotations +import importlib import sys from typing import Any from unittest.mock import MagicMock @@ -70,25 +71,38 @@ _clip_classes = [ "AudioArrayClip", "CompositeAudioClip", ] -for _cls in _clip_classes: - getattr(mock_moviepy, _cls).side_effect = lambda *_a, **_kw: create_mock_clip() -mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: create_mock_clip() -mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: create_mock_clip() -mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = ( - lambda *_a, **_kw: create_mock_clip() -) +_drawing_shape = (_H, _W) -# Drawing tools must return real numpy arrays (used in numpy ops) -mock_moviepy.video.tools.drawing.circle.return_value = np.zeros( - (_H, _W), dtype=np.float64 -) -mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros( - (_H, _W), dtype=np.float64 -) -mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros( - (_H, _W), dtype=np.float64 -) + +def _reset_side_effects() -> None: + """(Re)apply side_effect / return_value on the shared mock_moviepy.""" + for cls_name in _clip_classes: + getattr(mock_moviepy, cls_name).side_effect = lambda *_a, **_kw: ( + create_mock_clip() + ) + mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: ( + create_mock_clip() + ) + mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: ( + create_mock_clip() + ) + mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = ( + lambda *_a, **_kw: create_mock_clip() + ) + # Drawing tools must return real numpy arrays (used in numpy ops) + mock_moviepy.video.tools.drawing.circle.return_value = np.zeros( + _drawing_shape, dtype=np.float64 + ) + mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros( + _drawing_shape, dtype=np.float64 + ) + mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros( + _drawing_shape, dtype=np.float64 + ) + + +_reset_side_effects() # ── Install into sys.modules ───────────────────────────────────── _module_paths = [ @@ -117,7 +131,21 @@ def _install_moviepy_mocks() -> None: _install_moviepy_mocks() +_source_modules = [ + "python_pkg.moviepy_showcase.moviepy_showcase", + "python_pkg.moviepy_showcase._moviepy_clip_types", + "python_pkg.moviepy_showcase._moviepy_video_effects", + "python_pkg.moviepy_showcase._moviepy_audio_output", +] + + @pytest.fixture(autouse=True) def _reinstall_moviepy_mocks() -> None: """Ensure our moviepy mocks are active even if another conftest overwrote.""" _install_moviepy_mocks() + _reset_side_effects() + # Re-bind cached ``from moviepy import X`` references in source modules + # that may point to stale MagicMock children from a prior sys.modules entry. + for mod_name in _source_modules: + if mod_name in sys.modules: + importlib.reload(sys.modules[mod_name]) diff --git a/python_pkg/screen_locker/install_systemd.sh b/python_pkg/screen_locker/install_systemd.sh index 3fd3f83..ff9a7db 100755 --- a/python_pkg/screen_locker/install_systemd.sh +++ b/python_pkg/screen_locker/install_systemd.sh @@ -4,10 +4,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py" SERVICE_FILE="$SCRIPT_DIR/workout-locker.service" -TIMER_FILE="$SCRIPT_DIR/workout-locker.timer" USER_SERVICE_DIR="$HOME/.config/systemd/user" SERVICE_NAME="workout-locker.service" -TIMER_NAME="workout-locker.timer" # Check if service is already installed if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then @@ -26,9 +24,14 @@ fi # Create user systemd directory if it doesn't exist mkdir -p "$USER_SERVICE_DIR" -# Copy service and timer files to user systemd directory +# Remove old timer if it was previously installed +if systemctl --user is-active "workout-locker.timer" &>/dev/null; then + systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true +fi +rm -f "$USER_SERVICE_DIR/workout-locker.timer" + +# Copy service file to user systemd directory cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME" -cp "$TIMER_FILE" "$USER_SERVICE_DIR/$TIMER_NAME" # Update paths in the service file to use absolute paths REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" @@ -39,18 +42,16 @@ sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg. # Reload systemd daemon systemctl --user daemon-reload -# Enable the service to start on login and the timer for periodic checks +# Enable the service to start on login (one-shot, no periodic timer) systemctl --user enable "$SERVICE_NAME" -systemctl --user enable --now "$TIMER_NAME" echo "✓ Workout locker service installed" echo "✓ Service will start automatically on next login" echo "" echo "To start now: systemctl --user start workout-locker" echo "To check status: systemctl --user status workout-locker" -echo "To check timer: systemctl --user list-timers workout-locker.timer" echo "To stop: systemctl --user stop workout-locker" -echo "To disable autostart: systemctl --user disable workout-locker workout-locker.timer" +echo "To disable autostart: systemctl --user disable workout-locker" # Check autostart installation status echo "" diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 43f5a95..462cc2c 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -50,6 +50,21 @@ __all__ = [ _logger = logging.getLogger(__name__) +def _assert_not_under_pytest() -> None: + """Raise if the screen locker is being created inside a pytest run. + + Defence-in-depth: prevents a real fullscreen Tk window from locking + the user's screen when tests forget to mock ``tk.Tk``. + The check is cheap (one dict lookup) and only fires during testing. + """ + if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter": + msg = ( + "SAFETY: ScreenLocker.__init__ called under pytest with " + "real tkinter — tk.Tk is not mocked" + ) + raise RuntimeError(msg) + + class ScreenLocker( ShutdownMixin, PhoneVerificationMixin, @@ -64,6 +79,7 @@ class ScreenLocker( verify_only: bool = False, ) -> None: """Initialize screen locker with optional demo mode.""" + _assert_not_under_pytest() script_dir = Path(__file__).resolve().parent self.log_file = script_dir / "workout_log.json" self.verify_only = verify_only diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index c93d70c..7c54947 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -1,4 +1,12 @@ -"""Shared fixtures and helpers for screen_locker tests.""" +"""Shared fixtures and helpers for screen_locker tests. + +Safety: + ``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk`` + module reference inside ``screen_lock`` with a MagicMock and stubs + ``sys.exit``. This makes it physically impossible for any test to + create a real Tk root window, go fullscreen, or grab input — even if + the test forgets to request the explicit ``mock_tk`` fixture. +""" from __future__ import annotations @@ -12,7 +20,40 @@ import pytest from python_pkg.screen_locker.screen_lock import ScreenLocker if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterator + + +def _make_mock_tk() -> MagicMock: + """Build a MagicMock that stands in for the ``tkinter`` module.""" + mock = MagicMock() + mock_root = MagicMock() + mock_root.winfo_screenwidth.return_value = 1920 + mock_root.winfo_screenheight.return_value = 1080 + mock.Tk.return_value = mock_root + + mock_frame = MagicMock() + mock_frame.winfo_children.return_value = [] + mock.Frame.return_value = mock_frame + + # Keep real TclError so ``except tk.TclError`` still works. + mock.TclError = tk.TclError + return mock + + +@pytest.fixture(autouse=True) +def _block_real_tk_and_exit() -> Iterator[None]: + """Replace the whole ``tk`` module and ``sys.exit`` for every test. + + Patching the entire module (not just ``tk.Tk``) ensures that + **nothing** in tkinter can touch the real display server. + """ + mock = _make_mock_tk() + + with ( + patch("python_pkg.screen_locker.screen_lock.tk", mock), + patch("python_pkg.screen_locker.screen_lock.sys.exit"), + ): + yield @pytest.fixture diff --git a/python_pkg/screen_locker/tests/test_init_and_log.py b/python_pkg/screen_locker/tests/test_init_and_log.py index a781e69..c74e110 100644 --- a/python_pkg/screen_locker/tests/test_init_and_log.py +++ b/python_pkg/screen_locker/tests/test_init_and_log.py @@ -10,12 +10,29 @@ from unittest.mock import MagicMock, patch import pytest +from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path +class TestAssertNotUnderPytest: + """Tests for the _assert_not_under_pytest runtime guard.""" + + def test_raises_when_tk_is_real(self) -> None: + """Guard fires if tk.Tk is the real tkinter class under pytest.""" + with ( + patch("python_pkg.screen_locker.screen_lock.tk", tk), + pytest.raises(RuntimeError, match="SAFETY"), + ): + _assert_not_under_pytest() + + def test_silent_when_tk_is_mocked(self) -> None: + """Guard stays silent when tk is already mocked (normal test run).""" + _assert_not_under_pytest() + + class TestScreenLockerInit: """Tests for ScreenLocker initialization.""" diff --git a/python_pkg/screen_locker/workout-locker.timer b/python_pkg/screen_locker/workout-locker.timer deleted file mode 100644 index 4a01809..0000000 --- a/python_pkg/screen_locker/workout-locker.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Periodically check if workout was done today - -[Timer] -OnBootSec=5s -OnUnitActiveSec=15min -Persistent=true - -[Install] -WantedBy=timers.target diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py index 40762b2..c288dca 100644 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ b/python_pkg/steam_backlog_enforcer/game_install.py @@ -15,6 +15,29 @@ import time logger = logging.getLogger(__name__) +# Real Steam directory — used as a safety check to block destructive +# operations that leak through during testing. +_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() + + +def _assert_not_real_steam(path: Path) -> None: + """Raise if *path* is inside the real Steam directory. + + Defence-in-depth guard: even if test fixtures fail to + redirect ``STEAMAPPS_PATH``, destructive operations + (uninstall, rmtree, unlink) will refuse to touch real files. + """ + try: + path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) + except ValueError: + return # path is NOT under real Steam — safe to proceed + if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve(): + msg = ( + f"SAFETY: refusing destructive operation on real Steam path " + f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures" + ) + raise RuntimeError(msg) + def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: """Write user-facing CLI output to stdout. @@ -56,6 +79,7 @@ PROTECTED_APP_IDS = { 1007020, # Proton EasyAntiCheat Runtime # Games allowed to be installed anytime 3949040, # RV There Yet? + 2252570, } STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() @@ -312,6 +336,7 @@ def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool: game_name: Human-readable game name for logging. app_id: Steam application ID. """ + _assert_not_real_steam(manifest) try: if manifest.exists(): manifest.unlink() @@ -333,6 +358,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: """ success = True if install_dir and install_dir.is_dir(): + _assert_not_real_steam(install_dir) try: shutil.rmtree(install_dir) logger.info("Removed game files: %s", install_dir) @@ -343,6 +369,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: for subdir in ("shadercache", "compatdata"): cache_path = STEAMAPPS_PATH / subdir / str(app_id) if cache_path.is_dir(): + _assert_not_real_steam(cache_path) with contextlib.suppress(OSError): shutil.rmtree(cache_path) logger.debug("Removed %s/%d", subdir, app_id) diff --git a/python_pkg/steam_backlog_enforcer/hltb.py b/python_pkg/steam_backlog_enforcer/hltb.py index e091a09..c90016b 100644 --- a/python_pkg/steam_backlog_enforcer/hltb.py +++ b/python_pkg/steam_backlog_enforcer/hltb.py @@ -154,6 +154,10 @@ def _pick_best_hltb_entry( When a short name like "FAITH" matches both "FAITH" (demo) and "FAITH: The Unholy Trinity" (full game), prefer the full game since Steam often lists the full game under the shorter name. + + When an exact match like "Timberman" (26 h) competes against an + unrelated subtitle entry like "Timberman: The Big Adventure" (2 h), + the exact match wins because it has more hours. """ if not candidates: return None @@ -165,36 +169,72 @@ def _pick_best_hltb_entry( return usable[0] lower = search_name.lower() + best_exact = _find_exact_match(usable, lower) + best_extended = _find_best_extended(usable, lower) + return _resolve_exact_vs_extended(best_exact, best_extended, usable) + + +def _find_exact_match( + usable: list[tuple[dict[str, Any], float]], + lower: str, +) -> tuple[dict[str, Any], float] | None: + """Find best exact name/alias match (highest comp_100).""" + return next( + ( + (e, s) + for e, s in sorted( + usable, + key=lambda x: x[0].get("comp_100", 0), + reverse=True, + ) + if (e.get("game_name") or "").lower() == lower + or (e.get("game_alias") or "").lower() == lower + ), + None, + ) + + +def _find_best_extended( + usable: list[tuple[dict[str, Any], float]], + lower: str, +) -> tuple[dict[str, Any], float] | None: + """Find best extended entry ("Name: Subtitle" / "Name - Subtitle"). + + Skips subset entries (prologue, demo, etc.). + """ + best: tuple[dict[str, Any], float] | None = None for entry, sim in usable: entry_name = (entry.get("game_name") or "").lower() if entry_name.startswith((lower + ":", lower + " -")): suffix = entry_name[len(lower) :].lstrip(" :-") - if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES): - # Only prefer this extended entry when it has strictly more - # comp_100 than any exact-name match. This prevents - # "Killing Floor: Toy Master" (1.2 h) from beating - # "Killing Floor" (296 h) while still letting - # "FAITH: The Unholy Trinity" (7 h) beat "FAITH" (0.5 h demo). - extended_hours = entry.get("comp_100", 0) - best_exact = next( - ( - (e, s) - for e, s in sorted( - usable, - key=lambda x: x[0].get("comp_100", 0), - reverse=True, - ) - if (e.get("game_name") or "").lower() == lower - or (e.get("game_alias") or "").lower() == lower - ), - None, - ) - if ( - best_exact is not None - and best_exact[0].get("comp_100", 0) >= extended_hours - ): - return best_exact - return entry, sim + if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and ( + best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0) + ): + best = (entry, sim) + return best + + +def _resolve_exact_vs_extended( + best_exact: tuple[dict[str, Any], float] | None, + best_extended: tuple[dict[str, Any], float] | None, + usable: list[tuple[dict[str, Any], float]], +) -> tuple[dict[str, Any], float]: + """Decide between exact match, extended entry, or highest similarity.""" + if best_exact is not None and best_extended is not None: + exact_hours = best_exact[0].get("comp_100", 0) + extended_hours = best_extended[0].get("comp_100", 0) + # Prefer the extended entry only when it has strictly more hours + # than the exact match. This lets "FAITH: The Unholy Trinity" + # (7 h) beat "FAITH" (0.5 h demo) while preventing + # "Timberman: The Big Adventure" (2 h) from beating + # "Timberman" (26 h). + if extended_hours > exact_hours: + return best_extended + return best_exact + if best_exact is not None: + return best_exact + if best_extended is not None: + return best_extended # Fall back to highest similarity. return max(usable, key=lambda x: x[1]) diff --git a/python_pkg/steam_backlog_enforcer/tests/conftest.py b/python_pkg/steam_backlog_enforcer/tests/conftest.py index 037348b..689ad37 100644 --- a/python_pkg/steam_backlog_enforcer/tests/conftest.py +++ b/python_pkg/steam_backlog_enforcer/tests/conftest.py @@ -7,12 +7,14 @@ to temporary directories. This stops tests from accidentally: user's current assignment) - Reading real appmanifest files from ~/.local/share/Steam/steamapps - Modifying /etc/hosts via the store blocker + - Corrupting the HLTB cache on disk + - Launching real Steam or calling real subprocess commands """ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -57,6 +59,12 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", fake_steamapps, ), + # HLTB cache file (computed at import time from CONFIG_DIR, so + # patching CONFIG_DIR alone does not redirect it) + patch( + "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", + fake_config / "hltb_cache.json", + ), # /etc/hosts (store blocker) patch( "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", @@ -68,3 +76,43 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: ), ): yield + + +@pytest.fixture(autouse=True) +def _block_real_subprocesses() -> Iterator[None]: + """Block subprocess calls that could launch real Steam or modify system. + + Individual tests that need to test subprocess behaviour should + patch the specific module's ``subprocess.run`` / ``subprocess.Popen`` + themselves — their local patch will override this one. + """ + noop_run = MagicMock(return_value=MagicMock(returncode=1)) + noop_popen = MagicMock() + + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen", + noop_popen, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen", + noop_popen, + ), + ): + yield diff --git a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py b/python_pkg/steam_backlog_enforcer/tests/test_game_install.py index 6dcfe66..6b558cf 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_game_install.py @@ -6,7 +6,10 @@ import os from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +import pytest + from python_pkg.steam_backlog_enforcer.game_install import ( + _assert_not_real_steam, _echo, _ensure_steam_running, _get_real_user, @@ -22,7 +25,43 @@ from python_pkg.steam_backlog_enforcer.game_install import ( if TYPE_CHECKING: from pathlib import Path - import pytest + +PKG = "python_pkg.steam_backlog_enforcer.game_install" + + +class TestAssertNotRealSteam: + """Tests for the _assert_not_real_steam safety guard.""" + + def test_allows_tmp_path(self, tmp_path: Path) -> None: + """Non-Steam paths pass through without raising.""" + _assert_not_real_steam(tmp_path / "appmanifest_440.acf") + + def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None: + """Raises when path is under real Steam and STEAMAPPS_PATH is real.""" + real = tmp_path / "real_steam" + real.mkdir() + fake_manifest = real / "appmanifest_440.acf" + fake_manifest.touch() + with ( + patch(f"{PKG}._REAL_STEAMAPPS", real), + patch(f"{PKG}.STEAMAPPS_PATH", real), + pytest.raises(RuntimeError, match="SAFETY"), + ): + _assert_not_real_steam(fake_manifest) + + def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None: + """No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS.""" + real = tmp_path / "real_steam" + real.mkdir() + fake_manifest = real / "appmanifest_440.acf" + fake_manifest.touch() + redirected = tmp_path / "fake_steam" + redirected.mkdir() + with ( + patch(f"{PKG}._REAL_STEAMAPPS", real), + patch(f"{PKG}.STEAMAPPS_PATH", redirected), + ): + _assert_not_real_steam(fake_manifest) class TestEcho: diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb.py index 7ca1d02..959ca6f 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_hltb.py @@ -345,3 +345,89 @@ class TestPickBestHltbEntry: ) assert result is not None assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE" + + def test_exact_match_beats_different_subtitled_game(self) -> None: + """Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h). + + Unlike FAITH where the short name is a demo, here the short name + is the real full game and the subtitled entry is a different, shorter + game. The exact match should win because it has more hours. + """ + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + timberman_vs: dict[str, Any] = { + "game_name": "Timberman VS", + "comp_100": 23400, # 6.5 h + } + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (timberman_vs, 0.86), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_match_wins_even_when_extended_appears_first(self) -> None: + """Exact match wins regardless of candidate ordering.""" + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + # Extended entry appears first in the list. + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_only_no_extended(self) -> None: + """Exact match returned when no extended entries exist at all.""" + exact: dict[str, Any] = { + "game_name": "Celeste", + "comp_100": 180000, # 50 h + } + unrelated: dict[str, Any] = { + "game_name": "Unrelated Game", + "comp_100": 7200, + } + result = _pick_best_hltb_entry( + "Celeste", + [(exact, 1.0), (unrelated, 0.6)], + ) + assert result is not None + assert result[0]["game_name"] == "Celeste" + + def test_no_exact_no_extended_falls_back(self) -> None: + """When no exact or extended match exists, fall to highest similarity.""" + a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600} + b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200} + result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FooBaz" + + def test_extended_only_no_exact(self) -> None: + """Extended entry returned when no exact name match exists.""" + extended: dict[str, Any] = { + "game_name": "Neon: Ultimate Edition", + "comp_100": 36000, + } + unrelated: dict[str, Any] = { + "game_name": "Neon Lights", + "comp_100": 3600, + } + result = _pick_best_hltb_entry( + "Neon", + [(extended, 0.6), (unrelated, 0.7)], + ) + assert result is not None + assert result[0]["game_name"] == "Neon: Ultimate Edition" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py b/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py index f85779a..1d9f074 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py @@ -20,8 +20,6 @@ from python_pkg.steam_backlog_enforcer.library_hider import ( _wait_for_cdp_ready, _wait_for_collections_ready, ensure_steam_debug_port, - hide_other_games, - unhide_all_games, ) @@ -425,85 +423,3 @@ class TestEnsureSteamDebugPort: ), ): ensure_steam_debug_port() - - -class TestHideOtherGames: - """Tests for hide_other_games.""" - - def test_hides(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 5}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 5}', - ), - ): - count = hide_other_games([1, 2, 3], 1) - assert count == 5 - - def test_empty_list(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 0}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 0}', - ), - ): - count = hide_other_games([1], 1) - assert count == 0 - - def test_no_allowed(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 2}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 2}', - ), - ): - count = hide_other_games([1, 2], None) - assert count == 2 - - -class TestUnhideAllGames: - """Tests for unhide_all_games.""" - - def test_unhides(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": '{"count": 10}'}}}, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"count": 10}', - ), - ): - count = unhide_all_games([1, 2, 3]) - assert count == 10 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py index 7078779..affa1d4 100644 --- a/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py +++ b/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py @@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch from python_pkg.steam_backlog_enforcer.library_hider import ( _run_as_user, + hide_other_games, restart_steam, + unhide_all_games, ) PKG = "python_pkg.steam_backlog_enforcer.library_hider" @@ -116,3 +118,77 @@ class TestRestartSteam: patch(f"{PKG}._wait_for_cdp_ready", return_value=False), ): restart_steam() + + +class TestHideOtherGames: + """Tests for hide_other_games.""" + + def test_hides(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 5}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 5}', + ), + ): + count = hide_other_games([1, 2, 3], 1) + assert count == 5 + + def test_empty_list(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 0}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 0}', + ), + ): + count = hide_other_games([1], 1) + assert count == 0 + + def test_no_allowed(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 2}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 2}', + ), + ): + count = hide_other_games([1, 2], None) + assert count == 2 + + +class TestUnhideAllGames: + """Tests for unhide_all_games.""" + + def test_unhides(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={"result": {"result": {"value": '{"count": 10}'}}}, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"count": 10}', + ), + ): + count = unhide_all_games([1, 2, 3]) + assert count == 10 diff --git a/python_pkg/word_frequency/tests/test_translator_helpers_full.py b/python_pkg/word_frequency/tests/test_translator_helpers_full.py index 3270a67..a78b180 100644 --- a/python_pkg/word_frequency/tests/test_translator_helpers_full.py +++ b/python_pkg/word_frequency/tests/test_translator_helpers_full.py @@ -325,3 +325,25 @@ class TestArgosImportReload: importlib.reload(_helpers) # Restore original module state importlib.reload(_helpers) + + +class TestTorchImportReload: + """Test import-time torch ImportError branch via reload.""" + + def test_torch_import_failure_reload(self) -> None: + """Cover lines 19-20 (except ImportError: torch = None) via reload.""" + import builtins + + original_import = builtins.__import__ + + def _block_torch(name: str, *args: object, **kwargs: object) -> object: + if name == "torch": + msg = "mocked torch unavailable" + raise ImportError(msg) + return original_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=_block_torch): + importlib.reload(_helpers) + assert _helpers.torch is None + # Restore original module state + importlib.reload(_helpers) diff --git a/requirements.txt b/requirements.txt index 65a6fc7..43d08d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ +aiohttp>=3.9 beautifulsoup4>=4.0 berserk>=0.13 bottle>=0.12 genanki>=0.13 geopandas>=1.0 +howlongtobeatpy>=1.0 lxml>=5.0 # Optional dependencies for specific scripts (needed for full pylint analysis) diff --git a/scripts/check_no_binaries.sh b/scripts/check_no_binaries.sh index 7ee096e..6ed369f 100755 --- a/scripts/check_no_binaries.sh +++ b/scripts/check_no_binaries.sh @@ -59,6 +59,8 @@ is_allowed() { found=0 for file in "$@"; do + # Skip files not tracked by git (deleted from index or untracked) + git ls-files --error-unmatch "$file" >/dev/null 2>&1 || continue # Check if the file matches a blocked extension if echo "$file" | grep -qiE "$pattern"; then if is_allowed "$file"; then diff --git a/scripts/disk_cleanup_check.sh b/scripts/disk_cleanup_check.sh new file mode 100755 index 0000000..e868227 --- /dev/null +++ b/scripts/disk_cleanup_check.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# disk_cleanup_check.sh — Analyze disk usage and suggest (or perform) cleanup. +# +# Usage: +# ./disk_cleanup_check.sh # Dry-run: report only +# ./disk_cleanup_check.sh --clean # Interactive: prompt before each action +# +# Safe by default: nothing is deleted without --clean AND user confirmation. + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +RESET='\033[0m' + +CLEAN=false +if [[ "${1:-}" == "--clean" ]]; then + CLEAN=true +fi + +TOTAL_RECLAIMABLE=0 + +# ───────────────────── helpers ───────────────────── + +human_readable() { + local kb=$1 + if (( kb >= 1048576 )); then + printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)" + elif (( kb >= 1024 )); then + printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)" + else + printf "%d KB" "$kb" + fi +} + +dir_size_kb() { + local dir="$1" + if [[ -d "$dir" ]]; then + du -sk "$dir" 2>/dev/null | awk '{print $1}' + else + echo 0 + fi +} + +report() { + local label="$1" size_kb="$2" detail="${3:-}" + if (( size_kb > 0 )); then + local hr + hr=$(human_readable "$size_kb") + printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr" + if [[ -n "$detail" ]]; then + printf " ${CYAN}(%s)${RESET}" "$detail" + fi + printf "\n" + TOTAL_RECLAIMABLE=$(( TOTAL_RECLAIMABLE + size_kb )) + fi +} + +# Returns 0 if user confirms, 1 otherwise. Always 1 in dry-run. +confirm() { + local prompt="$1" + if $CLEAN; then + printf "${BOLD} → %s [y/N]: ${RESET}" "$prompt" + read -r ans + if [[ "$ans" =~ ^[Yy]$ ]]; then + return 0 + fi + fi + return 1 +} + +# Safe wrapper: confirm + action, never fails under errexit +try_clean() { + local prompt="$1" + shift + if confirm "$prompt"; then + "$@" + printf "${GREEN} ✓ Done${RESET}\n" + fi + return 0 +} + +# ───────────────────── checks ───────────────────── + +printf "\n${BOLD}=== Disk Cleanup Analysis ===${RESET}\n\n" +printf "${BOLD}%-40s %10s${RESET}\n" "Category" "Reclaimable" +printf '%s\n' "$(printf '%.0s─' {1..60})" + +# 1. Trash +trash_dir="$HOME/.local/share/Trash" +size=$(dir_size_kb "$trash_dir") +report "Trash" "$size" "empty trash" +if (( size > 0 )); then + try_clean "Empty trash?" \ + rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged" +fi + +# 2. Pacman cache +pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg") +if (( pacman_cache_size > 0 )); then + if command -v paccache &>/dev/null; then + pkg_count=$(ls /var/cache/pacman/pkg/ 2>/dev/null | wc -l) + report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \ + "sudo paccache -rk2" + try_clean "Clean pacman cache (keep last 2 versions)?" \ + sudo paccache -rk2 + else + report "Pacman cache (install pacman-contrib)" "$pacman_cache_size" \ + "pacman -S pacman-contrib && paccache -rk2" + fi +fi + +# 3. Yay build cache +yay_cache="$HOME/.cache/yay" +size=$(dir_size_kb "$yay_cache") +report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay" +if (( size > 0 )); then + try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}" +fi + +# 4. Pip cache +pip_cache="$HOME/.cache/pip" +size=$(dir_size_kb "$pip_cache") +report "Pip cache" "$size" "pip cache purge" +if (( size > 0 )); then + try_clean "Clear pip cache?" rm -rf "${pip_cache:?}" +fi + +# 5. uv cache +uv_cache="$HOME/.cache/uv" +size=$(dir_size_kb "$uv_cache") +report "uv cache" "$size" "uv cache clean" +if (( size > 0 )); then + try_clean "Clear uv cache?" rm -rf "${uv_cache:?}" +fi + +# 6. HuggingFace cache +hf_cache="$HOME/.cache/huggingface" +size=$(dir_size_kb "$hf_cache") +report "HuggingFace models" "$size" "manual review recommended" + +# 7. Suno cache +suno_cache="$HOME/.cache/suno" +size=$(dir_size_kb "$suno_cache") +report "Suno AI cache" "$size" "manual review recommended" + +# 8. Go build cache +go_cache="$HOME/.cache/go-build" +size=$(dir_size_kb "$go_cache") +report "Go build cache" "$size" "go clean -cache" +if (( size > 0 )); then + try_clean "Clear Go build cache?" rm -rf "${go_cache:?}" +fi + +# 9. Yarn cache +yarn_cache="$HOME/.cache/yarn" +size=$(dir_size_kb "$yarn_cache") +report "Yarn cache" "$size" "yarn cache clean" +if (( size > 0 )); then + try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}" +fi + +# 10. npm cache +npm_cache="$HOME/.npm/_cacache" +size=$(dir_size_kb "$npm_cache") +report "npm cache" "$size" "npm cache clean --force" +if (( size > 0 )); then + try_clean "Clear npm cache?" rm -rf "${npm_cache:?}" +fi + +# 11. Electron cache +electron_cache="$HOME/.cache/electron" +size=$(dir_size_kb "$electron_cache") +report "Electron cache" "$size" +if (( size > 0 )); then + try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}" +fi + +# 12. Thumbnails +thumb_cache="$HOME/.cache/thumbnails" +size=$(dir_size_kb "$thumb_cache") +report "Thumbnails cache" "$size" +if (( size > 0 )); then + try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}" +fi + +# 13. vscode-cpptools cache +cpptools_cache="$HOME/.cache/vscode-cpptools" +size=$(dir_size_kb "$cpptools_cache") +if (( size > 102400 )); then + report "VS Code cpptools cache" "$size" + try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}" +fi + +# 14. Bazel cache +bazel_cache="$HOME/.cache/bazel" +size=$(dir_size_kb "$bazel_cache") +report "Bazel cache" "$size" +if (( size > 0 )); then + try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}" +fi + +# 15. Browser caches (report only) +for browser_cache in \ + "$HOME/.cache/thorium" \ + "$HOME/.cache/google-chrome" \ + "$HOME/.cache/mozilla" \ + "$HOME/.cache/BraveSoftware"; do + if [[ -d "$browser_cache" ]]; then + name=$(basename "$browser_cache") + size=$(dir_size_kb "$browser_cache") + if (( size > 102400 )); then + report "Browser cache: $name" "$size" "close browser first" + fi + fi +done + +# 16. Docker +if command -v docker &>/dev/null; then + docker_dir="/var/lib/docker" + size=$(dir_size_kb "$docker_dir" || echo 0) + if (( size > 1048576 )); then + report "Docker (images+build cache)" "$size" "docker system prune -a" + try_clean "Docker system prune (removes all unused)?" \ + docker system prune -af --volumes + fi +fi + +# 17. Orphan packages +orphans=$(pacman -Qdtq 2>/dev/null || true) +orphan_count=0 +if [[ -n "$orphans" ]]; then + orphan_count=$(echo "$orphans" | wc -l) +fi +if (( orphan_count > 0 )); then + orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null \ + | awk '/Installed Size/{ + size=$4; unit=$5; + if (unit ~ /GiB/) total += size*1048576; + else if (unit ~ /MiB/) total += size*1024; + else if (unit ~ /KiB/) total += size; + } END{printf "%d", total}' || echo 0) + report "Orphan packages ($orphan_count)" "$orphan_size" \ + "sudo pacman -Rns \$(pacman -Qdtq)" + if confirm "Remove $orphan_count orphan packages?"; then + # shellcheck disable=SC2046 + sudo pacman -Rns $(pacman -Qdtq) --noconfirm + printf "${GREEN} ✓ Orphans removed${RESET}\n" + fi +fi + +# 18. Journal logs — vacuum to 200M +journal_line=$(journalctl --disk-usage 2>&1 || true) +journal_kb=0 +if [[ "$journal_line" =~ ([0-9.]+)G ]]; then + journal_kb=$(echo "${BASH_REMATCH[1]} * 1048576 / 1" | bc) +elif [[ "$journal_line" =~ ([0-9.]+)M ]]; then + journal_kb=$(echo "${BASH_REMATCH[1]} * 1024 / 1" | bc) +fi +if (( journal_kb > 204800 )); then + excess=$(( journal_kb - 204800 )) + report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M" + try_clean "Vacuum journal to 200M?" \ + sudo journalctl --vacuum-size=200M +fi + +# 19. AUR source dirs — old archives (>30 days) +aur_dir="$HOME/aur" +if [[ -d "$aur_dir" ]]; then + old_archives_kb=$(find "$aur_dir" \ + \( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \ + -o -name "*.zip" -o -name "*.tar.bz2" \) \ + -mtime +30 -printf '%k\n' 2>/dev/null \ + | awk '{t+=$1} END{print t+0}') + if (( old_archives_kb > 0 )); then + report "AUR old archives (>30d)" "$old_archives_kb" \ + "find ~/aur '*.pkg.tar.zst' -mtime +30 -delete" + if confirm "Delete old AUR archives (>30 days)?"; then + find "$aur_dir" \ + \( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \ + -o -name "*.zip" -o -name "*.tar.bz2" \) \ + -mtime +30 -delete + printf "${GREEN} ✓ Old AUR archives deleted${RESET}\n" + fi + fi +fi + +# ───────────────────── report-only items ───────────────────── + +printf "\n${BOLD}--- Report-only (manual review needed) ---${RESET}\n" + +for manual_entry in \ + "$HOME/Downloads/too_big:~/Downloads/too_big" \ + "$HOME/Downloads:~/Downloads total" \ + "$HOME/inne:~/inne" \ + "/Games:/Games — still playing?"; do + dir="${manual_entry%%:*}" + label="${manual_entry#*:}" + size=$(dir_size_kb "$dir") + if (( size > 0 )); then + hr=$(human_readable "$size") + printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \ + "$label" "$hr" + fi +done + +# ───────────────────── summary ───────────────────── + +printf "\n%s\n" "$(printf '%.0s─' {1..60})" +total_hr=$(human_readable "$TOTAL_RECLAIMABLE") +printf "${BOLD}Total auto-reclaimable: ${GREEN}%s${RESET}\n" "$total_hr" + +read -r used_kb total_kb used_pct <<< "$(df -k / | awk 'NR==2{print $3, $2, $5}')" +used_pct="${used_pct%\%}" +printf "${BOLD}Current usage: %s / %s (%s%%)${RESET}\n" \ + "$(human_readable "$used_kb")" "$(human_readable "$total_kb")" "$used_pct" + +if (( TOTAL_RECLAIMABLE > 0 )); then + new_used=$(( used_kb - TOTAL_RECLAIMABLE )) + if (( new_used < 0 )); then new_used=0; fi + new_pct=$(( new_used * 100 / total_kb )) + printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \ + "$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct" +fi + +if ! $CLEAN; then + printf "\n${CYAN}Run with --clean to interactively clean each category.${RESET}\n" +fi +printf "\n" diff --git a/test_input.wav b/test_input.wav deleted file mode 100644 index debde90..0000000 Binary files a/test_input.wav and /dev/null differ diff --git a/test_input_clean.wav b/test_input_clean.wav deleted file mode 100644 index 876330b..0000000 Binary files a/test_input_clean.wav and /dev/null differ