diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..d21063d --- /dev/null +++ b/.coveragerc @@ -0,0 +1,45 @@ +# Coverage configuration for Games XBlock +# Following Open edX standards + +[run] +branch = True +source = games +omit = + */migrations/* + */tests/* + */test_*.py + */__pycache__/* + */locale/* + tests/settings.py + tests/urls.py + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Have to re-enable the standard pragma + pragma: no cover + + # Don't complain about missing debug-only code + def __repr__ + def __str__ + + # Don't complain if tests don't hit defensive assertion code + raise AssertionError + raise NotImplementedError + + # Don't complain if non-runnable code isn't run + if 0: + if False: + if __name__ == .__main__.: + + # Don't complain about abstract methods + @abstract + +ignore_errors = True +precision = 2 + +[html] +directory = htmlcov + +[xml] +output = coverage.xml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..37d9c7d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,83 @@ +name: Run Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11'] + django-version: ['3.2', '4.2'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y gettext + + - name: Upgrade pip + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install package dependencies + run: pip install -e . + + - name: Install Django ${{ matrix.django-version }} + run: pip install "Django~=${{ matrix.django-version }}.0" + + - name: Install test requirements + run: pip install -r requirements/ci.txt + + - name: Run tests with coverage + run: | + pytest tests/ -v --cov=games --cov-report=term-missing --cov-report=xml --cov-report=html --cov-fail-under=80 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Archive coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report-${{ matrix.python-version }}-django-${{ matrix.django-version }} + path: htmlcov/ + retention-days: 30 + + quality: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r test-requirements.txt + + - name: Run quality checks + run: make quality diff --git a/.gitignore b/.gitignore index 14e9cc3..2734bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,20 @@ __pycache__ *.lcov - +.venv/ *.pyc # Build and distribution files *.egg-info/ dist/ build/ -.DS_Store \ No newline at end of file +.DS_Store + +# Test and coverage files +coverage.xml +.coverage +htmlcov/ +.pytest_cache/ +.tox/ +*.cover +.hypothesis/ diff --git a/API.md b/API.md new file mode 100644 index 0000000..fb811b0 --- /dev/null +++ b/API.md @@ -0,0 +1,654 @@ +# Games XBlock API Documentation + +This document describes the APIs available in the Games XBlock for implementing gamification features in Open edX courses. The XBlock supports two game types: **Flashcards** and **Matching**. + +## Table of Contents + +- [XBlock Fields](#xblock-fields) +- [Common APIs](#common-apis) +- [Flashcards Game APIs](#flashcards-game-apis) +- [Matching Game APIs](#matching-game-apis) +- [Data Structures](#data-structures) + +--- + +## XBlock Fields + +The Games XBlock uses the following fields to store configuration and state: + +### Content-Scoped Fields +- **`title`** (string): The title displayed in the XBlock. Default varies by game type. +- **`cards`** (list): List of card objects containing terms and definitions. +- **`list_length`** (integer): Number of cards in the list (for convenience). + +### Settings-Scoped Fields +- **`display_name`** (string): Display name for the XBlock. Default: `"Games XBlock"`. +- **`game_type`** (string): Type of game - either `"flashcards"` or `"matching"`. Default: `"matching"`. +- **`is_shuffled`** (boolean): Whether cards should be shuffled. Default: `true`. +- **`has_timer`** (boolean): Whether the game should display a timer. Default: `true`. + +### User State Fields +- **`best_time`** (integer): Best completion time in seconds for matching game (user-specific). + +--- + +## Common APIs + +These APIs work across both flashcards and matching game types. + +### `get_settings` + +**Type**: JSON Handler +**Description**: Retrieves current game configuration including game type, cards, shuffle setting, and timer setting. + +**Request**: +```json +{} +``` + +**Response**: +```json +{ + "game_type": "matching", + "cards": [ + { + "term": "Python", + "term_image": "http://example.com/python.png", + "definition": "A high-level programming language", + "definition_image": "", + "order": 0, + "card_key": "550e8400-e29b-41d4-a716-446655440000" + } + ], + "is_shuffled": true, + "has_timer": true +} +``` + +**Fields**: +- `game_type` (string): Current game type (`"flashcards"` or `"matching"`) +- `cards` (array): List of card objects (see [Card Object](#card-object)) +- `is_shuffled` (boolean): Shuffle setting +- `has_timer` (boolean): Timer setting + +--- + +### `save_settings` + +**Type**: JSON Handler +**Description**: Saves game type, shuffle setting, timer setting, and all cards in one API call. Validates card format and generates unique identifiers. + +**Request**: +```json +{ + "game_type": "flashcards", + "is_shuffled": true, + "has_timer": false, + "display_name": "My Vocabulary Game", + "cards": [ + { + "term": "Algorithm", + "term_image": "", + "definition": "A step-by-step procedure for solving a problem", + "definition_image": "http://example.com/algo.png" + }, + { + "term": "Data Structure", + "term_image": "http://example.com/ds.png", + "definition": "A way of organizing data efficiently", + "definition_image": "" + } + ] +} +``` + +**Response** (Success): +```json +{ + "success": true, + "game_type": "flashcards", + "cards": [ + { + "term": "Algorithm", + "term_image": "", + "definition": "A step-by-step procedure for solving a problem", + "definition_image": "http://example.com/algo.png", + "order": 0, + "card_key": "7c9e6679-7425-40de-944b-e07fc1f90ae7" + }, + { + "term": "Data Structure", + "term_image": "http://example.com/ds.png", + "definition": "A way of organizing data efficiently", + "definition_image": "", + "order": 1, + "card_key": "3f847e5d-8a6b-4c3e-9d2a-1b8f7c4e0d9a" + } + ], + "count": 2, + "is_shuffled": true, + "has_timer": false +} +``` + +**Response** (Error): +```json +{ + "success": false, + "error": "Each card must have term and definition" +} +``` + +**Validation Rules**: +- Each card must be an object/dictionary +- Each card must contain both `term` and `definition` fields +- Missing `card_key` will be auto-generated as UUID +- Missing image URLs default to empty strings + +--- + +### `upload_image` + +**Type**: HTTP Handler +**Description**: Uploads an image file to configured storage (S3 or default) and returns the URL. + +**Request**: +- **Method**: POST +- **Content-Type**: multipart/form-data +- **Parameters**: + - `file` (file): The image file to upload + +**Supported Extensions**: jpg, jpeg, png, gif, webp, svg + +**Response** (Success): +```json +{ + "success": true, + "url": "https://s3.amazonaws.com/bucket/games/block_id/abc123def456.png", + "filename": "my-image.png", + "file_path": "games/block_id/abc123def456.png" +} +``` + +**Response** (Error - No Extension): +```json +{ + "success": false, + "error": "File must have an extension" +} +``` + +**Response** (Error - Invalid Extension): +```json +{ + "success": false, + "error": "Unsupported file type '.bmp'. Allowed: gif, jpg, jpeg, png, svg, webp" +} +``` + +**Notes**: +- File is stored with MD5 hash to prevent duplicates +- File path format: `gamesxblock//.` + +--- + +### `delete_image_handler` + +**Type**: JSON Handler +**Description**: Deletes an image from storage by its key/path. + +**Request**: +```json +{ + "key": "gamesxblock/block_id/abc123def456.png" +} +``` + +**Response** (Success): +```json +{ + "success": true, + "key": "gamesxblock/block_id/abc123def456.png" +} +``` + +**Response** (Error - Missing Key): +```json +{ + "success": false, + "error": "Missing key" +} +``` + +**Response** (Error - File Not Found): +```json +{ + "success": false, + "key": "gamesxblock/block_id/nonexistent.png" +} +``` + +--- + +## Flashcards Game APIs + +### `student_view` (Flashcards) + +**Type**: XBlock View +**Description**: Renders the flashcards game interface for students. + +**Features**: +- Displays cards one at a time +- Flip animation to reveal definitions +- Navigate forward/backward through cards +- Optional shuffle on load +- Progress indicator + +**Rendered Context**: +```python +{ + "title": "Flashcards", + "list_length": 10, + "encoded_mapping": "eyJjYXJkcyI6W3sidGVybSI6...", # Base64-encoded card data + "obf_decoder": "function FlashcardsInit(r,e){...}", # Obfuscated initialization + "data_element_id": "abc123xyz789" +} +``` + +**Data Payload Structure** (decoded from `encoded_mapping`): +```json +{ + "cards": [ + { + "id": "card-uuid", + "term": "Front text", + "definition": "Back text", + "term_image": "http://example.com/front.png", + "definition_image": "http://example.com/back.png" + } + ], + "salt": "randomString123" +} +``` + +--- + +## Matching Game APIs + +### `student_view` (Matching) + +**Type**: XBlock View +**Description**: Renders the matching game interface for students where they match terms with definitions. + +**Features**: +- Two-column layout (terms on left, definitions on right) +- Click to match pairs +- Optional timer to track completion time +- Multi-page support for large card sets (6 matches per page by default) +- Confetti animation on completion +- Best time tracking + +**Rendered Context**: +```python +{ + "title": "Matching Game", + "list_length": 12, + "all_pages": [ + { + "left_items": [{"text": "Python", "index": 0}, ...], + "right_items": [{"text": "Programming language", "index": 1}, ...] + } + ], + "has_timer": true, + "total_pages": 2, + "encoded_mapping": "eyJrZXkiOiJnQUFBQUFBQUFBQUE...", + "obf_decoder": "function MatchingInit(r,e){...}", + "data_element_id": "xyz789abc123" +} +``` + +**Data Payload Structure** (decoded from `encoded_mapping`): +```json +{ + "key": "gAAAAABh...encrypted_mapping_data", + "pages": [ + { + "left_items": [ + {"text": "Python", "index": 0}, + {"text": "JavaScript", "index": 2} + ], + "right_items": [ + {"text": "Programming language", "index": 1}, + {"text": "Web scripting", "index": 3} + ] + } + ] +} +``` + +**Security Note**: The matching key mapping is encrypted using Fernet symmetric encryption with a key derived from the block ID and a salt. + +--- + +### `start_matching_game` + +**Type**: JSON Handler +**Description**: Decrypts and returns the key mapping for matching game validation. Called when game starts to enable client-side match validation. + +**Request**: +```json +{ + "matching_key": "gAAAAABh3k2J..." +} +``` + +**Response** (Success): +```json +{ + "success": true, + "data": [ + "550e8400-e29b-41d4-a716-446655440001", + "3f847e5d-8a6b-4c3e-9d2a-1b8f7c4e0d02", + null, + null + ] +} +``` + +**Response** (Error - Missing Key): +```json +{ + "success": false, + "error": "Missing matching_key parameter" +} +``` + +**Response** (Error - Decryption Failed): +```json +{ + "success": false, + "error": "Failed to decrypt mapping: Invalid token" +} +``` + +**Notes**: +- The `data` array contains UUID-like strings representing valid matches +- Each index maps to its matching pair's index +- Encryption prevents students from easily discovering correct matches + +--- + +### `complete_matching_game` + +**Type**: JSON Handler +**Description**: Records completion time and updates the user's best time if the new time is better. + +**Request**: +```json +{ + "new_time": 45 +} +``` + +**Response**: +```json +{ + "new_time": 45, + "prev_best_time": 52 +} +``` + +**Response** (First Completion): +```json +{ + "new_time": 67, + "prev_best_time": null +} +``` + +**Fields**: +- `new_time` (integer): Completion time in seconds for this attempt +- `prev_best_time` (integer|null): Previous best time, or `null` if first completion + +**Behavior**: +- If `new_time < prev_best_time`, updates `best_time` field +- If `prev_best_time` is `null`, sets `best_time` to `new_time` + +--- + +### `refresh_game` + +**Type**: HTTP Handler +**Description**: Re-renders the matching game view with newly shuffled cards (if shuffle is enabled). + +**Request**: +- **Method**: GET or POST +- **Parameters**: None + +**Response**: +- **Content-Type**: text/html; charset=UTF-8 +- **Body**: Full HTML fragment of the matching game with new shuffle + +**Use Case**: Called when student wants to retry the game with different arrangement. + +--- + +## Data Structures + +### Card Object + +Represents a single term-definition pair in the game. + +```json +{ + "term": "Algorithm", + "term_image": "http://example.com/algo.png", + "definition": "A step-by-step procedure for solving a problem", + "definition_image": "http://example.com/algo-diagram.png", + "order": 0, + "card_key": "7c9e6679-7425-40de-944b-e07fc1f90ae7" +} +``` + +**Fields**: +- **`term`** (string, required): The term/question/front side text +- **`term_image`** (string, optional): URL to image for the term +- **`definition`** (string, required): The definition/answer/back side text +- **`definition_image`** (string, optional): URL to image for the definition +- **`order`** (integer, optional): Display order (auto-generated if missing) +- **`card_key`** (string, optional): Unique identifier (auto-generated as UUID if missing) + +--- + +### Item Object (Matching Game) + +Represents a draggable/clickable item in the matching game interface. + +```json +{ + "text": "Python", + "index": 0 +} +``` + +**Fields**: +- **`text`** (string): Display text for the item +- **`index`** (integer): Unique index used for match validation + +--- + +### Page Object (Matching Game) + +Represents one page of matches in a multi-page matching game. + +```json +{ + "left_items": [ + {"text": "Python", "index": 0}, + {"text": "JavaScript", "index": 2} + ], + "right_items": [ + {"text": "Programming language", "index": 1}, + {"text": "Web scripting", "index": 3} + ] +} +``` + +**Fields**: +- **`left_items`** (array): List of item objects for the left column (terms) +- **`right_items`** (array): List of item objects for the right column (definitions) + +**Configuration**: Default is 6 matches per page (configurable via `CONFIG.MATCHES_PER_PAGE`). + +--- + +## Error Handling + +All JSON handlers return a consistent error format: + +```json +{ + "success": false, + "error": "Error message describing what went wrong" +} +``` + +Common error scenarios: +- Missing required parameters +- Invalid data format +- Encryption/decryption failures +- File upload issues (invalid extension, file too large, etc.) +- Storage backend errors + +--- + +## Security Features + +### Encryption +- Matching game uses Fernet symmetric encryption for key mapping +- Encryption key derived from block ID and salt (consistent across requests) +- Prevents students from easily discovering correct matches + +### Obfuscation +- JavaScript initialization functions use randomized variable names +- Data embedded in DOM with random element IDs +- Payload encoded in Base64 before embedding +- Makes it harder to reverse-engineer game logic + +### Validation +- Server-side validation of card structure +- File extension whitelist for uploads +- MD5 hashing prevents duplicate uploads +- Storage key validation before deletion + +--- + +## Configuration Constants + +Key configuration values (from `games/constants.py`): + +- **`MATCHES_PER_PAGE`**: 6 - Number of term-definition pairs per page in matching game +- **`RANDOM_STRING_LENGTH`**: 16 - Length of random keys for obfuscation +- **`SALT_LENGTH`**: 12 - Length of salt for payload obfuscation +- **`ENCRYPTION_SALT`**: Internal salt for key generation +- **`PATH_PREFIX`**: "gamesxblock" - Prefix for uploaded file paths + +--- + +## Usage Examples + +### Example 1: Create a Flashcards Game + +```python +# 1. Save settings with flashcards type +response = xblock.save_settings({ + "game_type": "flashcards", + "is_shuffled": true, + "has_timer": false, + "display_name": "Spanish Vocabulary", + "cards": [ + { + "term": "Hola", + "definition": "Hello" + }, + { + "term": "Adiós", + "definition": "Goodbye" + } + ] +}) + +# 2. Upload images for cards (if needed) +# POST to upload_image handler with multipart/form-data + +# 3. Students view the flashcards via student_view +# They can flip cards, navigate, and study terms +``` + +### Example 2: Create a Matching Game with Timer + +```python +# 1. Save settings with matching type +response = xblock.save_settings({ + "game_type": "matching", + "is_shuffled": true, + "has_timer": true, + "display_name": "Computer Science Terms", + "cards": [ + { + "term": "Algorithm", + "definition": "Step-by-step procedure" + }, + { + "term": "Variable", + "definition": "Named storage location" + }, + { + "term": "Loop", + "definition": "Repeated execution" + } + ] +}) + +# 2. Students play the matching game +# - Game loads encrypted key mapping +# - Timer starts when first match is made +# - On completion, time is submitted to complete_matching_game +# - Best time is tracked per user +``` + +### Example 3: Upload and Use Images + +```javascript +// Frontend: Upload image file +const formData = new FormData(); +formData.append('file', imageFile); + +fetch(uploadImageUrl, { + method: 'POST', + body: formData +}).then(response => response.json()) + .then(data => { + if (data.success) { + // Use data.url in card definition + const card = { + term: "Python Logo", + term_image: data.url, + definition: "High-level programming language", + definition_image: "" + }; + } + }); +``` + +--- + +## Internationalization (i18n) + +The Games XBlock supports multiple languages: + +- **Default**: English (en) +- **Supported**: Spanish (es_419) +- All user-facing strings are translatable using Django's translation framework +- Use `gettext()` or `gettext_lazy()` for runtime translation +- Translation files located in `games/locale/` diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4ae0ca5 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +include LICENSE +include README.md +include setup.py + +recursive-include games/static * +recursive-include games/locale * +recursive-exclude games/locale *.pyc +recursive-exclude games/locale __pycache__ + +global-exclude *.py[co] +global-exclude __pycache__ +global-exclude *.so diff --git a/Makefile b/Makefile index 5326475..4289116 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Locales to support LOCALES := en ar es_419 fr zh_CN -.PHONY: help extract_translations compile_translations +.PHONY: help extract_translations compile_translations test test-coverage quality install-test-requirements help: ## Display this help message @echo "Please use \`make ' where is one of:" @@ -21,4 +21,20 @@ compile_translations: ## Compile .po files to .mo files @find games/locale -type f \( -name "*-partial.po" -o -name "*-partial.mo" \) -delete @echo "Compilation complete. Check games/locale/*/LC_MESSAGES/*.mo files" +install-test-requirements: ## Install test requirements + @echo "Installing test requirements..." + pip install -r requirements/test.txt +test: ## Run unit tests + @echo "Running unit tests..." + pytest tests/ -v + +test-coverage: ## Run tests with coverage report + @echo "Running tests with coverage..." + pytest tests/ -v --cov=games --cov-report=term-missing --cov-report=html --cov-report=xml + +quality: ## Run code quality checks + @echo "Running pylint..." + DJANGO_SETTINGS_MODULE=tests.settings pylint games --load-plugins=pylint_django --exit-zero + @echo "Running pycodestyle..." + pycodestyle games --exclude=migrations,tests --max-line-length=120 || true \ No newline at end of file diff --git a/games/handlers/flashcards.py b/games/handlers/flashcards.py index 40408ce..0691e68 100644 --- a/games/handlers/flashcards.py +++ b/games/handlers/flashcards.py @@ -14,6 +14,7 @@ from ..constants import CARD_FIELD, CONFIG, DEFAULT from .common import CommonHandlers + class FlashcardsHandlers: """Handlers specific to the flashcards game.""" diff --git a/games/locale/config.yaml b/games/locale/config.yaml index d8b05a4..29ba1e4 100644 --- a/games/locale/config.yaml +++ b/games/locale/config.yaml @@ -4,13 +4,13 @@ # The language of the source strings (the code) source_locale: en -# Locales for which we want to generate dummy translations (for testing) -dummy_locales: - - eo # Esperanto used as dummy locale +# Supported locales +locales: + - en # English + - es_419 # Spanish (Latin America) # Directories to search for translatable strings -locales: - - games/locale +locales_dir: games/locale # Python source paths source_messages_dir: games diff --git a/games/locale/es_419/LC_MESSAGES/django.mo b/games/locale/es_419/LC_MESSAGES/django.mo new file mode 100644 index 0000000..08e2293 Binary files /dev/null and b/games/locale/es_419/LC_MESSAGES/django.mo differ diff --git a/games/locale/es_419/LC_MESSAGES/django.po b/games/locale/es_419/LC_MESSAGES/django.po new file mode 100644 index 0000000..df42447 --- /dev/null +++ b/games/locale/es_419/LC_MESSAGES/django.po @@ -0,0 +1,114 @@ +# edX translation file. +# Copyright (C) 2025 EdX +# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE. +# EdX Team , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: 0.1a\n" +"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" +"POT-Creation-Date: 2023-06-13 08:00+0000\n" +"PO-Revision-Date: 2023-06-13 09:00+0000\n" +"Last-Translator: \n" +"Language-Team: openedx-translation \n" +"Language: es_419\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: games/games.py +msgid "The title of the block to be displayed in the xblock." +msgstr "El título del bloque que se mostrará en el xblock." + +#: games/games.py +msgid "" +"The kind of game this xblock is responsible for ('flashcards' or 'matching' " +"for now)." +msgstr "" +"El tipo de juego del que este xblock es responsable ('flashcards' o " +"'matching' por ahora)." + +#: games/games.py +msgid "The list of terms and definitions." +msgstr "La lista de términos y definiciones." + +#: games/games.py +msgid "A field for the length of the list for convenience." +msgstr "Un campo para la longitud de la lista por conveniencia." + +#: games/games.py +msgid "Best time (in seconds) for completing the matching game." +msgstr "Mejor tiempo (en segundos) para completar el juego de emparejamiento." + +#: games/games.py +msgid "Whether the cards should be shuffled" +msgstr "Si las tarjetas deben mezclarse" + +#: games/games.py +msgid "Whether the game should have a timer" +msgstr "Si el juego debe tener un temporizador" + +#: games/handlers/common.py +msgid "Each card must be an object" +msgstr "Cada tarjeta debe ser un objeto" + +#: games/handlers/common.py +msgid "Each card must have term and definition" +msgstr "Cada tarjeta debe tener término y definición" + +#: games/static/html/flashcards.html +msgid "Click each card to reveal the definition" +msgstr "Haz clic en cada tarjeta para revelar la definición" + +#: games/static/html/flashcards.html games/static/html/matching.html +msgid "Start" +msgstr "Comenzar" + +#: games/static/html/flashcards.html +msgid "Previous card" +msgstr "Tarjeta anterior" + +#: games/static/html/flashcards.html +msgid "Next card" +msgstr "Siguiente tarjeta" + +#: games/static/html/flashcards.html games/static/html/matching.html +msgid "Help" +msgstr "Ayuda" + +#: games/static/html/matching.html +msgid "Match each term with the correct definition" +msgstr "Empareja cada término con la definición correcta" + +#: games/static/html/matching.html +msgid "Congratulations!" +msgstr "¡Felicitaciones!" + +#: games/static/html/matching.html +msgid "A new personal best!" +msgstr "¡Un nuevo récord personal!" + +#: games/static/html/matching.html +msgid "Previous best:" +msgstr "Mejor anterior:" + +#: games/static/html/matching.html +msgid "You completed the matching game in " +msgstr "Completaste el juego de emparejamiento en " + +#: games/static/html/matching.html +msgid "Keep up the good work!" +msgstr "¡Sigue con el buen trabajo!" + +#: games/static/html/matching.html +msgid "Your personal best" +msgstr "Tu mejor récord personal" + +#: games/static/html/matching.html +msgid "You successfully matched all items." +msgstr "Emparejaste exitosamente todos los elementos." + +#: games/static/html/matching.html +msgid "Play again" +msgstr "Jugar de nuevo" diff --git a/games/toggles.py b/games/toggles.py index fb57c32..58ac94e 100644 --- a/games/toggles.py +++ b/games/toggles.py @@ -22,11 +22,11 @@ def is_games_xblock_enabled(): """ Return Waffle flag for enabling the games xblock on legacy studio. """ - try: + try: # adding this try/catch cause # goCD deployment is getting MySQL connection failure during make pull_translations django command, # caused by Django trying to evaluate a waffle flag (legacy_studio.enable_games_xblock) at import time # when CMS is not up return ENABLE_GAMES_XBLOCK.is_enabled() except Exception: - return False + return False diff --git a/games/utils.py b/games/utils.py index 8ec9af8..8d2cf56 100644 --- a/games/utils.py +++ b/games/utils.py @@ -44,6 +44,7 @@ def get_gamesxblock_storage(): def delete_image(storage, key: str): + """Delete an image from storage if it exists.""" if storage.exists(key): storage.delete(key) return True diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..9e685ef --- /dev/null +++ b/pytest.ini @@ -0,0 +1,36 @@ +[pytest] +# Django settings for tests +DJANGO_SETTINGS_MODULE = tests.settings +pythonpath = . + +# Test discovery patterns +python_files = test*.py +python_classes = Test* +python_functions = test_* + +# Paths to search for tests +testpaths = tests + +# Output options +addopts = + --verbose + --strict-markers + --tb=short + --cov=games + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-config=.coveragerc + --cov-fail-under=80 + --no-cov-on-fail + +# Markers for test organization +markers = + unit: Unit tests + integration: Integration tests + slow: Slow running tests + +# Ignore warnings from dependencies +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..85cdda6 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,9 @@ +# Base requirements for Games XBlock +# These are the minimum requirements to run the XBlock + +XBlock>=1.2.0 +web-fragments>=0.3.0 +Django>=2.2 +django-waffle==5.0.0 +edx-toggles==5.4.1 +cryptography>=3.4.8 diff --git a/requirements/ci.txt b/requirements/ci.txt new file mode 100644 index 0000000..8e54387 --- /dev/null +++ b/requirements/ci.txt @@ -0,0 +1,11 @@ +# CI/CD specific requirements for Games XBlock +# Used in GitHub Actions and other CI environments + +-r test.txt + +# Coverage reporting +coverage==7.4.0 +codecov==2.1.13 + +# CI-specific tools +tox-battery==0.6.2 diff --git a/requirements/test.txt b/requirements/test.txt new file mode 100644 index 0000000..5c540b2 --- /dev/null +++ b/requirements/test.txt @@ -0,0 +1,25 @@ +# Testing requirements for Games XBlock +# Includes base requirements plus testing tools + +-r base.txt + +# Core testing frameworks +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-django==4.7.0 + +# Mocking and data-driven tests +mock==5.1.0 +ddt==1.7.1 + +# XBlock testing +XBlock>=5.0.0 + +# Code quality +pylint==3.0.3 +pylint-django==2.5.5 +pycodestyle==2.11.1 + +# Additional utilities +factory-boy==3.3.0 +faker==22.0.0 diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..4c04844 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,23 @@ +# Testing requirements for Open edX Games XBlock +# Following Open edX standard testing packages + +# Core testing frameworks +pytest==7.4.3 +pytest-cov==4.1.0 +pytest-django==4.7.0 + +# Mocking and data-driven tests +mock==5.1.0 +ddt==1.7.1 + +# XBlock testing +XBlock>=5.0.0 + +# Code quality +pylint==3.0.3 +pylint-django==2.5.5 +pycodestyle==2.11.1 + +# Additional utilities +factory-boy==3.3.0 +faker==22.0.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..47e7094 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +""" +Games XBlock tests package. +""" diff --git a/tests/handlers/__init__.py b/tests/handlers/__init__.py new file mode 100644 index 0000000..d321a26 --- /dev/null +++ b/tests/handlers/__init__.py @@ -0,0 +1,3 @@ +""" +Tests for game handlers. +""" diff --git a/tests/handlers/test_common_handlers.py b/tests/handlers/test_common_handlers.py new file mode 100644 index 0000000..392f243 --- /dev/null +++ b/tests/handlers/test_common_handlers.py @@ -0,0 +1,272 @@ +""" +Tests for common handlers. +""" +import json +import hashlib +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase +from faker import Faker +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds + +from games.games import GamesXBlock +from games.handlers.common import CommonHandlers +from games.constants import GAME_TYPE, DEFAULT + + +class TestCommonHandlers(TestCase): + """Tests for common handler methods.""" + + def setUp(self): + """Set up test fixtures.""" + self.fake = Faker() + self.runtime = Mock() + usage_id = Mock() + usage_id.block_id = self.fake.uuid4() + self.scope_ids = ScopeIds(self.fake.uuid4(), "games", self.fake.uuid4(), usage_id) + self.field_data = DictFieldData({ + 'game_type': GAME_TYPE.FLASHCARDS, + 'cards': [], + 'is_shuffled': self.fake.boolean(), + 'has_timer': self.fake.boolean(), + 'display_name': self.fake.catch_phrase(), + }) + self.xblock = GamesXBlock(self.runtime, self.field_data, self.scope_ids) + + # Tests for generate_unique_var_names + def test_generate_unique_var_names(self): + """Test generating unique variable names for obfuscation.""" + keys = [self.fake.word(), self.fake.word(), self.fake.word()] + names = CommonHandlers.generate_unique_var_names(keys) + + self.assertEqual(len(names), 3) + for key in keys: + self.assertIn(key, names) + + # Check all names are unique + values = list(names.values()) + self.assertEqual(len(values), len(set(values))) + + # Check name lengths + for name in values: + self.assertGreaterEqual(len(name), 3) + self.assertLessEqual(len(name), 6) + self.assertTrue(name.islower()) + + # Tests for generate_encryption_key + def test_generate_encryption_key(self): + """Test encryption key generation.""" + key = CommonHandlers.generate_encryption_key(self.xblock) + + self.assertIsInstance(key, bytes) + self.assertEqual(len(key), 44) # Base64-encoded 32-byte key + + # Tests for encrypt_data and decrypt_data + def test_encrypt_decrypt_data(self): + """Test encrypting and decrypting data.""" + key = CommonHandlers.generate_encryption_key(self.xblock) + original_data = {'term': self.fake.word(), 'definition': self.fake.sentence(), 'index': self.fake.random_int()} + + encrypted = CommonHandlers.encrypt_data(original_data, key) + self.assertIsInstance(encrypted, str) + self.assertNotEqual(encrypted, json.dumps(original_data)) + + decrypted = CommonHandlers.decrypt_data(encrypted, key) + self.assertEqual(decrypted, original_data) + + # Tests for get_settings + def test_get_settings(self): + """Test getting game settings.""" + self.xblock.game_type = GAME_TYPE.MATCHING + cards = [{'term': self.fake.word(), 'definition': self.fake.word()}] + is_shuffled = self.fake.boolean() + has_timer = self.fake.boolean() + self.xblock.cards = cards + self.xblock.is_shuffled = is_shuffled + self.xblock.has_timer = has_timer + + result = CommonHandlers.get_settings(self.xblock, {}) + + self.assertEqual(result['game_type'], GAME_TYPE.MATCHING) + self.assertEqual(result['cards'], cards) + self.assertEqual(result['is_shuffled'], is_shuffled) + self.assertEqual(result['has_timer'], has_timer) + + # Tests for upload_image + @patch('games.handlers.common.get_gamesxblock_storage') + def test_upload_image_success(self, mock_get_storage): + """Test successful image upload.""" + mock_storage = Mock() + image_path = self.fake.file_path(extension='jpg') + image_url = self.fake.image_url() + mock_storage.save.return_value = image_path + mock_storage.url.return_value = image_url + mock_get_storage.return_value = mock_storage + + mock_file = Mock() + mock_file.read.return_value = self.fake.binary(length=100) + + filename = self.fake.file_name(extension='jpg') + mock_file_obj = Mock() + mock_file_obj.file = mock_file + mock_file_obj.filename = filename + + request = Mock() + request.params = {'file': mock_file_obj} + + response = CommonHandlers.upload_image(self.xblock, request) + + self.assertEqual(response.status_code, 200) + response_data = json.loads(response.body.decode()) + self.assertTrue(response_data['success']) + self.assertEqual(response_data['url'], image_url) + self.assertEqual(response_data['filename'], filename) + + @patch('games.handlers.common.get_gamesxblock_storage') + def test_upload_image_no_extension(self, mock_get_storage): + """Test upload fails when file has no extension.""" + mock_file_obj = Mock() + mock_file_obj.file = Mock() + mock_file_obj.filename = self.fake.word() + + request = Mock() + request.params = {'file': mock_file_obj} + + response = CommonHandlers.upload_image(self.xblock, request) + + self.assertEqual(response.status_code, 400) + response_data = json.loads(response.body.decode()) + self.assertFalse(response_data['success']) + self.assertIn('extension', response_data['error']) + + @patch('games.handlers.common.get_gamesxblock_storage') + def test_upload_image_invalid_extension(self, mock_get_storage): + """Test upload fails with unsupported file type.""" + mock_file_obj = Mock() + mock_file_obj.file = Mock() + mock_file_obj.filename = self.fake.file_name(extension='exe') + + request = Mock() + request.params = {'file': mock_file_obj} + + response = CommonHandlers.upload_image(self.xblock, request) + + self.assertEqual(response.status_code, 400) + response_data = json.loads(response.body.decode()) + self.assertFalse(response_data['success']) + self.assertIn('Unsupported file type', response_data['error']) + + # Tests for save_settings + def test_save_settings_flashcards(self): + """Test saving settings for flashcards game.""" + display_name = self.fake.catch_phrase() + data = { + 'game_type': GAME_TYPE.FLASHCARDS, + 'is_shuffled': self.fake.boolean(), + 'has_timer': self.fake.boolean(), + 'display_name': display_name, + 'cards': [ + {'term': self.fake.word(), 'definition': self.fake.sentence()}, + {'term': self.fake.word(), 'definition': self.fake.sentence()}, + ] + } + + result = CommonHandlers.save_settings(self.xblock, data) + + self.assertTrue(result['success']) + self.assertEqual(self.xblock.game_type, GAME_TYPE.FLASHCARDS) + self.assertEqual(self.xblock.display_name, display_name) + self.assertEqual(self.xblock.is_shuffled, data['is_shuffled']) + self.assertEqual(self.xblock.has_timer, data['has_timer']) + self.assertEqual(len(self.xblock.cards), 2) + self.assertEqual(self.xblock.list_length, 2) + + def test_save_settings_matching(self): + """Test saving settings for matching game.""" + data = { + 'game_type': GAME_TYPE.MATCHING, + 'is_shuffled': self.fake.boolean(), + 'has_timer': self.fake.boolean(), + 'display_name': self.fake.catch_phrase(), + 'cards': [ + {'term': self.fake.word(), 'definition': self.fake.sentence()}, + ] + } + + result = CommonHandlers.save_settings(self.xblock, data) + + self.assertTrue(result['success']) + self.assertEqual(self.xblock.game_type, GAME_TYPE.MATCHING) + self.assertEqual(result['count'], 1) + + def test_save_settings_missing_required_fields(self): + """Test save fails when cards missing required fields.""" + data = { + 'game_type': GAME_TYPE.FLASHCARDS, + 'cards': [ + {'term': self.fake.word()}, # Missing definition + ] + } + + result = CommonHandlers.save_settings(self.xblock, data) + + self.assertFalse(result['success']) + self.assertIn('term and definition', result['error']) + + def test_save_settings_invalid_card_format(self): + """Test save fails when card is not a dictionary.""" + data = { + 'game_type': GAME_TYPE.FLASHCARDS, + 'cards': [self.fake.sentence()] + } + + result = CommonHandlers.save_settings(self.xblock, data) + + self.assertFalse(result['success']) + self.assertIn('object', result['error']) + + # Tests for format_as_uuid_like + def test_format_as_uuid_like(self): + """Test UUID-like formatting for obfuscation.""" + key_hex = self.fake.hexify(text='^^^^^^^^', upper=False) + index = self.fake.random_int(min=0, max=1000) + + result = CommonHandlers.format_as_uuid_like(key_hex, index) + + # Check format: 8-4-4-4-12 (36 chars + 4 hyphens = 40 chars with hyphens) + parts = result.split('-') + self.assertEqual(len(parts), 5) + self.assertEqual(len(parts[0]), 8) + self.assertEqual(len(parts[1]), 4) + self.assertEqual(len(parts[2]), 4) + self.assertEqual(len(parts[3]), 4) + self.assertEqual(len(parts[4]), 12) + + # First part should be the key + self.assertEqual(parts[0], key_hex) + + # Tests for delete_image_handler + @patch('games.handlers.common.get_gamesxblock_storage') + @patch('games.handlers.common.delete_image') + def test_delete_image_handler_success(self, mock_delete, mock_get_storage): + """Test successful image deletion.""" + mock_delete.return_value = True + mock_storage = Mock() + mock_get_storage.return_value = mock_storage + + image_key = self.fake.file_path(extension='jpg') + data = {'key': image_key} + result = CommonHandlers.delete_image_handler(self.xblock, data) + + self.assertTrue(result['success']) + self.assertEqual(result['key'], image_key) + mock_delete.assert_called_once_with(mock_storage, image_key) + + @patch('games.handlers.common.get_gamesxblock_storage') + def test_delete_image_handler_missing_key(self, mock_get_storage): + """Test delete fails when key is missing.""" + data = {} + result = CommonHandlers.delete_image_handler(self.xblock, data) + + self.assertFalse(result['success']) + self.assertIn('Missing key', result['error']) diff --git a/tests/handlers/test_flashcards_handlers.py b/tests/handlers/test_flashcards_handlers.py new file mode 100644 index 0000000..e4b0c9a --- /dev/null +++ b/tests/handlers/test_flashcards_handlers.py @@ -0,0 +1,68 @@ +""" +Tests for flashcards and matching handlers. +""" +import json +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase +from faker import Faker +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds + +from games.games import GamesXBlock +from games.handlers.flashcards import FlashcardsHandlers +from games.constants import GAME_TYPE, CARD_FIELD + + +class TestFlashcardsHandlers(TestCase): + """Tests for flashcards handler methods.""" + + def setUp(self): + """Set up test fixtures.""" + self.fake = Faker() + self.runtime = Mock() + self.scope_ids = ScopeIds(self.fake.uuid4(), "games", self.fake.uuid4(), self.fake.uuid4()) + self.title = self.fake.catch_phrase() + self.field_data = DictFieldData({ + 'game_type': GAME_TYPE.FLASHCARDS, + 'cards': [ + {CARD_FIELD.CARD_KEY: self.fake.uuid4(), CARD_FIELD.TERM: self.fake.word(), CARD_FIELD.DEFINITION: self.fake.sentence()}, + {CARD_FIELD.CARD_KEY: self.fake.uuid4(), CARD_FIELD.TERM: self.fake.word(), CARD_FIELD.DEFINITION: self.fake.sentence()}, + ], + 'is_shuffled': self.fake.boolean(), + 'has_timer': self.fake.boolean(), + 'title': self.title, + }) + self.xblock = GamesXBlock(self.runtime, self.field_data, self.scope_ids) + + # Tests for student_view rendering + @patch('games.handlers.flashcards.pkg_resources.resource_string') + def test_student_view_renders_fragment(self, mock_resource_string): + """Test student view returns a fragment with cards.""" + mock_resource_string.return_value = b'
{{ title }}
' + + frag = FlashcardsHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + self.assertIn(self.title, frag.content) + + @patch('games.handlers.flashcards.pkg_resources.resource_string') + def test_student_view_with_no_cards(self, mock_resource_string): + """Test student view with no cards.""" + mock_resource_string.return_value = b'
{{ list_length }}
' + self.xblock.cards = [] + + frag = FlashcardsHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + self.assertIn('0', frag.content) + + @patch('games.handlers.flashcards.pkg_resources.resource_string') + def test_student_view_with_shuffled_cards(self, mock_resource_string): + """Test student view with shuffled cards.""" + mock_resource_string.return_value = b'
{{ list_length }}
' + self.xblock.is_shuffled = True + + frag = FlashcardsHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + self.assertIn('2', frag.content) \ No newline at end of file diff --git a/tests/handlers/test_matching_handlers.py b/tests/handlers/test_matching_handlers.py new file mode 100644 index 0000000..5e971e6 --- /dev/null +++ b/tests/handlers/test_matching_handlers.py @@ -0,0 +1,159 @@ +""" +Tests for flashcards and matching handlers. +""" +import json +from unittest.mock import Mock, patch, MagicMock +from django.test import TestCase +from faker import Faker +from xblock.field_data import DictFieldData +from xblock.fields import ScopeIds + +from games.games import GamesXBlock +from games.handlers.matching import MatchingHandlers +from games.constants import GAME_TYPE, CARD_FIELD + + +class TestMatchingHandlers(TestCase): + """Tests for matching handler methods.""" + + def setUp(self): + """Set up test fixtures.""" + self.fake = Faker() + self.runtime = Mock() + usage_id = Mock() + usage_id.block_id = self.fake.uuid4() + self.scope_ids = ScopeIds(self.fake.uuid4(), "games", self.fake.uuid4(), usage_id) + self.title = self.fake.catch_phrase() + self.field_data = DictFieldData({ + 'game_type': GAME_TYPE.MATCHING, + 'cards': [ + {CARD_FIELD.TERM: self.fake.word(), CARD_FIELD.DEFINITION: self.fake.sentence()}, + {CARD_FIELD.TERM: self.fake.word(), CARD_FIELD.DEFINITION: self.fake.sentence()}, + ], + 'is_shuffled': self.fake.boolean(), + 'has_timer': self.fake.boolean(), + 'title': self.title, + 'best_time': None, + }) + self.xblock = GamesXBlock(self.runtime, self.field_data, self.scope_ids) + + # Tests for student_view rendering + @patch('games.handlers.matching.pkg_resources.resource_string') + def test_student_view_renders_fragment(self, mock_resource_string): + """Test student view returns a fragment.""" + mock_resource_string.return_value = b'
{{ title }}
' + + frag = MatchingHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + self.assertIn(self.title, frag.content) + + @patch('games.handlers.matching.pkg_resources.resource_string') + def test_student_view_with_shuffled_cards(self, mock_resource_string): + """Test student view with shuffled cards.""" + mock_resource_string.return_value = b'
{{ list_length }}
' + self.xblock.is_shuffled = True + + frag = MatchingHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + self.assertIn('2', frag.content) + + @patch('games.handlers.matching.pkg_resources.resource_string') + def test_student_view_with_multiple_pages(self, mock_resource_string): + """Test student view with multiple pages of cards.""" + mock_resource_string.return_value = b'
{{ total_pages }}
' + # Add enough cards to create multiple pages (6 cards per page by default) + cards = [] + for i in range(15): + cards.append({CARD_FIELD.TERM: self.fake.word(), CARD_FIELD.DEFINITION: self.fake.sentence()}) + self.xblock.cards = cards + + frag = MatchingHandlers.student_view(self.xblock) + + self.assertIsNotNone(frag) + + # Tests for get_matching_key_mapping + def test_get_matching_key_mapping_success(self): + """Test getting matching key mapping with valid encrypted data.""" + from games.handlers.common import CommonHandlers + + key = CommonHandlers.generate_encryption_key(self.xblock) + test_data = [self.fake.word(), self.fake.word()] + encrypted = CommonHandlers.encrypt_data(test_data, key) + + data = {'matching_key': encrypted} + result = MatchingHandlers.get_matching_key_mapping(self.xblock, data) + + self.assertTrue(result['success']) + self.assertEqual(result['data'], test_data) + + def test_get_matching_key_mapping_missing_key(self): + """Test get_matching_key_mapping fails when key is missing.""" + data = {} + result = MatchingHandlers.get_matching_key_mapping(self.xblock, data) + + self.assertFalse(result['success']) + self.assertIn('Missing matching_key', result['error']) + + def test_get_matching_key_mapping_invalid_encryption(self): + """Test get_matching_key_mapping fails with invalid encrypted data.""" + data = {'matching_key': self.fake.sha256()} + result = MatchingHandlers.get_matching_key_mapping(self.xblock, data) + + self.assertFalse(result['success']) + self.assertIn('Failed to decrypt', result['error']) + + # Tests for refresh_game + @patch.object(MatchingHandlers, 'student_view') + def test_refresh_game(self, mock_student_view): + """Test refresh game handler.""" + mock_frag = Mock() + refresh_content = f'
{self.fake.sentence()}
' + mock_frag.content = refresh_content + mock_student_view.return_value = mock_frag + + request = Mock() + response = MatchingHandlers.refresh_game(self.xblock, request) + + self.assertEqual(response.status_code, 200) + self.assertIn(refresh_content, response.body.decode()) + mock_student_view.assert_called_once() + + # Tests for complete_matching_game + def test_complete_matching_game_first_time(self): + """Test completing matching game for the first time.""" + new_time = self.fake.pyfloat(min_value=10.0, max_value=100.0, right_digits=2) + data = {'new_time': new_time} + + result = MatchingHandlers.complete_matching_game(self.xblock, data) + + self.assertEqual(result['new_time'], new_time) + self.assertIsNone(result['prev_best_time']) + self.assertEqual(self.xblock.best_time, new_time) + + def test_complete_matching_game_with_better_time(self): + """Test completing matching game with a better time.""" + prev_time = self.fake.pyfloat(min_value=50.0, max_value=100.0, right_digits=2) + new_time = prev_time - self.fake.pyfloat(min_value=5.0, max_value=20.0, right_digits=2) + self.xblock.best_time = prev_time + data = {'new_time': new_time} + + result = MatchingHandlers.complete_matching_game(self.xblock, data) + + self.assertEqual(result['new_time'], new_time) + self.assertEqual(result['prev_best_time'], prev_time) + self.assertEqual(self.xblock.best_time, new_time) + + def test_complete_matching_game_with_worse_time(self): + """Test completing matching game with a worse time.""" + prev_time = self.fake.pyfloat(min_value=20.0, max_value=50.0, right_digits=2) + new_time = prev_time + self.fake.pyfloat(min_value=5.0, max_value=20.0, right_digits=2) + self.xblock.best_time = prev_time + data = {'new_time': new_time} + + result = MatchingHandlers.complete_matching_game(self.xblock, data) + + self.assertEqual(result['new_time'], new_time) + self.assertEqual(result['prev_best_time'], prev_time) + self.assertEqual(self.xblock.best_time, prev_time) # Should not update diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..e66f4f7 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,121 @@ +""" +Django settings for Games XBlock tests. + +Following Open edX testing standards. +""" + +import os +import tempfile + +# Build paths +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# SECURITY WARNING: keep the secret key used in testing secret! +SECRET_KEY = 'test-secret-key-for-games-xblock' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'games', + 'waffle', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'waffle.middleware.WaffleMiddleware', +] + +ROOT_URLCONF = 'games.tests.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +# Database +# Use in-memory SQLite for tests +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + 'ATOMIC_REQUESTS': True, + } +} + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(tempfile.gettempdir(), 'games_xblock_static') + +# Media files +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(tempfile.gettempdir(), 'games_xblock_media') + +# Default storage for tests +DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' + +# XBlock configuration +XBLOCK_MIXINS = [] +XBLOCK_SELECT_FUNCTION = None + +# Waffle configuration for tests +WAFFLE_FLAG_MODEL = 'waffle.Flag' +WAFFLE_SWITCH_MODEL = 'waffle.Switch' +WAFFLE_SAMPLE_MODEL = 'waffle.Sample' + +# Logging configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'games': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + }, + }, +} + +# Games XBlock specific settings +GAMESXBLOCK_STORAGE = None # Use default storage for tests diff --git a/tests/test_games.py b/tests/test_games.py new file mode 100644 index 0000000..fdc176c --- /dev/null +++ b/tests/test_games.py @@ -0,0 +1,283 @@ +""" +Unit tests for games.py - GamesXBlock core functionality. + +Following Open edX testing standards with pytest and ddt. +""" + +import json +from unittest.mock import Mock, patch +import pytest +from xblock.field_data import DictFieldData +from xblock.test.tools import TestRuntime + +from games.games import GamesXBlock +from games.constants import DEFAULT, GAME_TYPE + + +@pytest.mark.django_db +class TestGamesXBlock: + """Test cases for GamesXBlock.""" + + def setup_method(self): + """Set up test fixtures.""" + self.runtime = TestRuntime() + self.field_data = DictFieldData({ + 'title': 'Test Game', + 'game_type': GAME_TYPE.MATCHING, + 'cards': [], + 'is_shuffled': False, + 'has_timer': True, + }) + self.block = GamesXBlock( + self.runtime, + field_data=self.field_data, + scope_ids=Mock( + usage_id=Mock(block_id='test-block-id'), + user_id='test-user-id', + ) + ) + + def test_init_block(self): + """Test that the XBlock initializes correctly.""" + assert self.block.title == 'Test Game' + assert self.block.game_type == GAME_TYPE.MATCHING + assert self.block.cards == [] + assert self.block.is_shuffled is False + assert self.block.has_timer is True + + def test_default_values(self): + """Test default field values.""" + default_block = GamesXBlock( + self.runtime, + field_data=DictFieldData({}), + scope_ids=Mock( + usage_id=Mock(block_id='test-block-id'), + user_id='test-user-id', + ) + ) + assert default_block.title == DEFAULT.MATCHING_TITLE + assert default_block.display_name == DEFAULT.DISPLAY_NAME + assert default_block.game_type == DEFAULT.GAME_TYPE + assert default_block.cards == [] + assert default_block.is_shuffled == DEFAULT.IS_SHUFFLED + assert default_block.has_timer == DEFAULT.HAS_TIMER + assert default_block.best_time is None + + def test_resource_string(self): + """Test resource_string helper method.""" + # This tests that the method doesn't crash + # Actual resource loading requires the package to be installed + with pytest.raises(Exception): + # Will raise if resource doesn't exist, which is expected in test + self.block.resource_string('nonexistent.txt') + + @patch('games.handlers.matching.MatchingHandlers.student_view') + def test_student_view_matching(self, mock_matching_view): + """Test student_view routes to MatchingHandlers for matching game.""" + mock_fragment = Mock() + mock_matching_view.return_value = mock_fragment + + self.block.game_type = GAME_TYPE.MATCHING + result = self.block.student_view() + + mock_matching_view.assert_called_once() + assert result == mock_fragment + + @patch('games.handlers.flashcards.FlashcardsHandlers.student_view') + def test_student_view_flashcards(self, mock_flashcards_view): + """Test student_view routes to FlashcardsHandlers for flashcards game.""" + mock_fragment = Mock() + mock_flashcards_view.return_value = mock_fragment + + self.block.game_type = GAME_TYPE.FLASHCARDS + result = self.block.student_view() + + mock_flashcards_view.assert_called_once() + assert result == mock_fragment + + def test_workbench_scenarios(self): + """Test workbench scenarios are defined.""" + scenarios = GamesXBlock.workbench_scenarios() + assert isinstance(scenarios, list) + assert len(scenarios) > 0 + # Check structure of scenarios + for scenario in scenarios: + assert isinstance(scenario, tuple) + assert len(scenario) == 2 + assert isinstance(scenario[0], str) # Title + assert isinstance(scenario[1], str) # XML + + +@pytest.mark.django_db +class TestGamesXBlockHandlers: + """Test XBlock handler methods.""" + + def setup_method(self): + """Set up test fixtures.""" + self.runtime = TestRuntime() + self.block = GamesXBlock( + self.runtime, + field_data=DictFieldData({ + 'title': 'Test Game', + 'game_type': GAME_TYPE.MATCHING, + 'cards': [ + { + 'term': 'Test Term', + 'definition': 'Test Definition', + 'term_image': '', + 'definition_image': '', + 'order': '0', + 'card_key': 'test-key-1' + } + ], + }), + scope_ids=Mock( + usage_id=Mock(block_id='test-block-id'), + user_id='test-user-id', + ) + ) + + @patch('games.handlers.common.CommonHandlers.get_settings') + def test_get_settings_handler(self, mock_handler): + """Test get_settings JSON handler delegates to CommonHandlers.""" + mock_handler.return_value = { + 'success': True, + 'game_type': GAME_TYPE.MATCHING, + 'cards': [], + } + + # Create mock request with method and body attributes + mock_request = Mock() + mock_request.method = 'POST' + mock_request.body = json.dumps({}).encode('utf-8') + + result = self.block.get_settings(mock_request, '') + + # Verify the handler was called with correct arguments + mock_handler.assert_called_once_with(self.block, {}, '') + # Result is a Response object wrapping the mocked return value + assert result.status == '200 OK' + + @patch('games.handlers.common.CommonHandlers.save_settings') + def test_save_settings_handler(self, mock_handler): + """Test save_settings JSON handler delegates to CommonHandlers.""" + mock_handler.return_value = {'success': True} + + data = { + 'game_type': GAME_TYPE.FLASHCARDS, + 'title': 'New Title', + 'cards': [], + } + + # Create mock request with method and body attributes + mock_request = Mock() + mock_request.method = 'POST' + mock_request.body = json.dumps(data).encode('utf-8') + + result = self.block.save_settings(mock_request, '') + + # Verify the handler was called + mock_handler.assert_called_once() + # Result is a Response object + assert result.status == '200 OK' + + @patch('games.handlers.common.CommonHandlers.upload_image') + def test_upload_image_handler(self, mock_handler): + """Test upload_image handler.""" + mock_response = Mock() + mock_response.status = 200 + mock_handler.return_value = mock_response + + mock_request = Mock() + result = self.block.upload_image(mock_request, '') + + mock_handler.assert_called_once_with(self.block, mock_request, '') + assert result == mock_response + + @patch('games.handlers.matching.MatchingHandlers.complete_matching_game') + def test_complete_matching_game_handler(self, mock_handler): + """Test complete_matching_game handler delegates to MatchingHandlers.""" + mock_handler.return_value = { + 'success': True, + 'is_best_time': True, + 'best_time': 30, + } + + # Create mock request with method and body attributes + mock_request = Mock() + mock_request.method = 'POST' + mock_request.body = json.dumps({'time': 30}).encode('utf-8') + + result = self.block.complete_matching_game(mock_request, '') + + # Verify the handler was called + mock_handler.assert_called_once() + # Result is a Response object + assert result.status == '200 OK' + + @patch('games.handlers.matching.MatchingHandlers.get_matching_key_mapping') + def test_start_matching_game_handler(self, mock_handler): + """Test start_matching_game handler delegates to MatchingHandlers.""" + mock_handler.return_value = { + 'success': True, + 'key_mapping': {}, + } + + # Create mock request with method and body attributes + mock_request = Mock() + mock_request.method = 'POST' + mock_request.body = json.dumps({}).encode('utf-8') + + result = self.block.start_matching_game(mock_request, '') + + # Verify the handler was called + mock_handler.assert_called_once() + # Result is a Response object + assert result.status == '200 OK' + + +@pytest.mark.django_db +class TestGamesXBlockFieldScopes: + """Test field scopes and persistence.""" + + def setup_method(self): + """Set up test fixtures.""" + self.runtime = TestRuntime() + self.block = GamesXBlock( + self.runtime, + field_data=DictFieldData({}), + scope_ids=Mock( + usage_id=Mock(block_id='test-block-id'), + user_id='test-user-id', + ) + ) + + def test_title_is_content_scoped(self): + """Test that title field has content scope.""" + from xblock.fields import Scope + assert self.block.fields['title'].scope == Scope.content + + def test_cards_is_content_scoped(self): + """Test that cards field has content scope.""" + from xblock.fields import Scope + assert self.block.fields['cards'].scope == Scope.content + + def test_best_time_is_user_state_scoped(self): + """Test that best_time field has user_state scope.""" + from xblock.fields import Scope + assert self.block.fields['best_time'].scope == Scope.user_state + + def test_game_type_is_settings_scoped(self): + """Test that game_type field has settings scope.""" + from xblock.fields import Scope + assert self.block.fields['game_type'].scope == Scope.settings + + def test_is_shuffled_is_settings_scoped(self): + """Test that is_shuffled field has settings scope.""" + from xblock.fields import Scope + assert self.block.fields['is_shuffled'].scope == Scope.settings + + def test_has_timer_is_settings_scoped(self): + """Test that has_timer field has settings scope.""" + from xblock.fields import Scope + assert self.block.fields['has_timer'].scope == Scope.settings diff --git a/tests/test_toggles.py b/tests/test_toggles.py new file mode 100644 index 0000000..1a6de28 --- /dev/null +++ b/tests/test_toggles.py @@ -0,0 +1,32 @@ +""" +Tests for games toggles. +""" +from unittest.mock import patch, MagicMock +from django.test import TestCase + +from games.toggles import is_games_xblock_enabled, ENABLE_GAMES_XBLOCK + + +class TestGamesXBlockToggles(TestCase): + """Tests for games xblock toggle functions.""" + + @patch.object(ENABLE_GAMES_XBLOCK, 'is_enabled') + def test_is_games_xblock_enabled_when_flag_is_on(self, mock_is_enabled): + """Test that function returns True when waffle flag is enabled.""" + mock_is_enabled.return_value = True + self.assertTrue(is_games_xblock_enabled()) + mock_is_enabled.assert_called_once() + + @patch.object(ENABLE_GAMES_XBLOCK, 'is_enabled') + def test_is_games_xblock_enabled_when_flag_is_off(self, mock_is_enabled): + """Test that function returns False when waffle flag is disabled.""" + mock_is_enabled.return_value = False + self.assertFalse(is_games_xblock_enabled()) + mock_is_enabled.assert_called_once() + + @patch.object(ENABLE_GAMES_XBLOCK, 'is_enabled') + def test_is_games_xblock_enabled_handles_exception(self, mock_is_enabled): + """Test that function returns False when exception occurs.""" + mock_is_enabled.side_effect = Exception("Database connection error") + self.assertFalse(is_games_xblock_enabled()) + mock_is_enabled.assert_called_once() diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 0000000..c0799d2 --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,70 @@ +""" +Tests for internationalization (i18n) support. +""" +from django.test import TestCase +from django.utils import translation +from django.utils.translation import gettext as _ +from faker import Faker + + +class TestTranslations(TestCase): + """Tests for translation support.""" + + def setUp(self): + """Set up test fixtures.""" + self.fake = Faker() + + def test_english_translations(self): + """Test that English translations are loaded correctly.""" + with translation.override('en'): + translated = _("Each card must be an object") + self.assertEqual(translated, "Each card must be an object") + + def test_spanish_translations(self): + """Test that Spanish translations are loaded correctly.""" + with translation.override('es_419'): + translated = _("Each card must be an object") + self.assertEqual(translated, "Cada tarjeta debe ser un objeto") + + def test_spanish_start_button(self): + """Test Spanish translation for Start button.""" + with translation.override('es_419'): + translated = _("Start") + self.assertEqual(translated, "Comenzar") + + def test_spanish_help_text(self): + """Test Spanish translation for Help.""" + with translation.override('es_419'): + translated = _("Help") + self.assertEqual(translated, "Ayuda") + + def test_spanish_congratulations(self): + """Test Spanish translation for Congratulations.""" + with translation.override('es_419'): + translated = _("Congratulations!") + self.assertEqual(translated, "¡Felicitaciones!") + + def test_spanish_play_again(self): + """Test Spanish translation for Play again.""" + with translation.override('es_419'): + translated = _("Play again") + self.assertEqual(translated, "Jugar de nuevo") + + def test_spanish_card_instructions(self): + """Test Spanish translation for card instructions.""" + with translation.override('es_419'): + translated = _("Click each card to reveal the definition") + self.assertEqual(translated, "Haz clic en cada tarjeta para revelar la definición") + + def test_spanish_matching_instructions(self): + """Test Spanish translation for matching instructions.""" + with translation.override('es_419'): + translated = _("Match each term with the correct definition") + self.assertEqual(translated, "Empareja cada término con la definición correcta") + + def test_translation_fallback_to_english(self): + """Test that untranslated strings fall back to English.""" + with translation.override('es_419'): + # This string is not in our translations + untranslated = _("This string does not exist") + self.assertEqual(untranslated, "This string does not exist") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..6eaaf5a --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,166 @@ +""" +Unit tests for utils.py - Games XBlock utility functions. + +Following Open edX testing standards with pytest and ddt. +""" + +import pytest +from unittest.mock import Mock, patch +from django.core.exceptions import ImproperlyConfigured +from django.core.files.storage import default_storage + +from games.utils import get_gamesxblock_storage, delete_image + + +@pytest.mark.django_db +class TestGetGamesxblockStorage: + """Test cases for get_gamesxblock_storage function.""" + + @patch('games.utils.settings') + def test_returns_default_storage_when_no_settings(self, mock_settings): + """Test returns default_storage when GAMESXBLOCK_STORAGE not configured.""" + mock_settings.GAMESXBLOCK_STORAGE = None + + result = get_gamesxblock_storage() + + assert result == default_storage + + @patch('games.utils.settings') + @patch('games.utils.import_string') + def test_returns_custom_storage_when_configured(self, mock_import_string, mock_settings): + """Test returns custom storage when properly configured.""" + # Mock storage class and instance + mock_storage_instance = Mock() + mock_storage_class = Mock(return_value=mock_storage_instance) + mock_import_string.return_value = mock_storage_class + + # Configure settings + mock_settings.GAMESXBLOCK_STORAGE = { + 'storage_class': 'myapp.storage.CustomStorage', + 'settings': { + 'bucket_name': 'test-bucket', + 'location': 'games/', + } + } + + result = get_gamesxblock_storage() + + mock_import_string.assert_called_once_with('myapp.storage.CustomStorage') + mock_storage_class.assert_called_once_with( + bucket_name='test-bucket', + location='games/', + ) + assert result == mock_storage_instance + + @patch('games.utils.settings') + def test_raises_error_when_storage_class_missing(self, mock_settings): + """Test raises ImproperlyConfigured when storage_class is missing.""" + mock_settings.GAMESXBLOCK_STORAGE = { + 'settings': {'bucket_name': 'test-bucket'} + } + + with pytest.raises(ImproperlyConfigured) as exc_info: + get_gamesxblock_storage() + + assert 'storage_class missing' in str(exc_info.value) + + @patch('games.utils.settings') + @patch('games.utils.import_string') + def test_raises_error_when_import_fails(self, mock_import_string, mock_settings): + """Test raises ImproperlyConfigured when storage class import fails.""" + mock_import_string.side_effect = ImportError('Module not found') + + mock_settings.GAMESXBLOCK_STORAGE = { + 'storage_class': 'nonexistent.storage.Class', + 'settings': {} + } + + with pytest.raises(ImproperlyConfigured) as exc_info: + get_gamesxblock_storage() + + assert 'Failed importing storage class' in str(exc_info.value) + + @patch('games.utils.settings') + @patch('games.utils.import_string') + def test_raises_error_when_initialization_fails(self, mock_import_string, mock_settings): + """Test raises ImproperlyConfigured when storage initialization fails.""" + mock_storage_class = Mock(side_effect=TypeError('Invalid arguments')) + mock_import_string.return_value = mock_storage_class + + mock_settings.GAMESXBLOCK_STORAGE = { + 'storage_class': 'myapp.storage.CustomStorage', + 'settings': {'invalid_param': 'value'} + } + + with pytest.raises(ImproperlyConfigured) as exc_info: + get_gamesxblock_storage() + + assert 'Failed initializing storage' in str(exc_info.value) + + @patch('games.utils.settings') + @patch('games.utils.import_string') + def test_handles_empty_settings_dict(self, mock_import_string, mock_settings): + """Test handles empty or None settings dict.""" + mock_storage_instance = Mock() + mock_storage_class = Mock(return_value=mock_storage_instance) + mock_import_string.return_value = mock_storage_class + + # Test with None settings + mock_settings.GAMESXBLOCK_STORAGE = { + 'storage_class': 'myapp.storage.CustomStorage', + 'settings': None + } + + result = get_gamesxblock_storage() + + mock_storage_class.assert_called_once_with() + assert result == mock_storage_instance + + +@pytest.mark.django_db +class TestDeleteImage: + """Test cases for delete_image function.""" + + def test_deletes_existing_image(self): + """Test successfully deletes an existing image.""" + mock_storage = Mock() + mock_storage.exists.return_value = True + + result = delete_image(mock_storage, 'path/to/image.jpg') + + mock_storage.exists.assert_called_once_with('path/to/image.jpg') + mock_storage.delete.assert_called_once_with('path/to/image.jpg') + assert result is True + + def test_returns_false_for_nonexistent_image(self): + """Test returns False when image doesn't exist.""" + mock_storage = Mock() + mock_storage.exists.return_value = False + + result = delete_image(mock_storage, 'path/to/nonexistent.jpg') + + mock_storage.exists.assert_called_once_with('path/to/nonexistent.jpg') + mock_storage.delete.assert_not_called() + assert result is False + + def test_handles_empty_key(self): + """Test handles empty key gracefully.""" + mock_storage = Mock() + mock_storage.exists.return_value = False + + result = delete_image(mock_storage, '') + + mock_storage.exists.assert_called_once_with('') + assert result is False + + def test_handles_special_characters_in_key(self): + """Test handles keys with special characters.""" + mock_storage = Mock() + mock_storage.exists.return_value = True + + special_key = 'path/to/image with spaces & special-chars.jpg' + result = delete_image(mock_storage, special_key) + + mock_storage.exists.assert_called_once_with(special_key) + mock_storage.delete.assert_called_once_with(special_key) + assert result is True diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..a98c412 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,7 @@ +""" +URL configuration for Games XBlock tests. +""" + +urlpatterns = [ + # Add URL patterns if needed for tests +]