From e1bc16c73a7d5108aeed549768716cc0005f3d73 Mon Sep 17 00:00:00 2001 From: Kai Niebes Date: Sat, 23 Nov 2024 16:31:55 +0100 Subject: [PATCH] Add simple testing framework, for testing import and export of (for now) IIIF manifests and (later) Blender scenes --- .github/workflows/test_manifests.yml | 92 +++++++++++ run_blender_with_plugin.py | 154 ++++++++++++++++++ tests/blender_scenes/.gitkeep | 0 tests/iiif_manifests/0101_model_origin.json | 36 ++++ .../0201_perspective_camera.json | 43 +++++ tests/run_tests.sh | 36 ++++ 6 files changed, 361 insertions(+) create mode 100644 .github/workflows/test_manifests.yml create mode 100644 run_blender_with_plugin.py create mode 100644 tests/blender_scenes/.gitkeep create mode 100644 tests/iiif_manifests/0101_model_origin.json create mode 100644 tests/iiif_manifests/0201_perspective_camera.json create mode 100644 tests/run_tests.sh diff --git a/.github/workflows/test_manifests.yml b/.github/workflows/test_manifests.yml new file mode 100644 index 0000000..3649a8b --- /dev/null +++ b/.github/workflows/test_manifests.yml @@ -0,0 +1,92 @@ +name: Test IIIF Manifests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test-manifests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libxrender1 \ + libxxf86vm1 \ + libxfixes3 \ + libxi6 \ + libxkbcommon0 \ + libxkbcommon-x11-0 \ + libgl1 \ + libglu1-mesa \ + libsm6 \ + libxext6 \ + libx11-6 \ + libxcb1 \ + libtinfo5 + + - name: Cache Blender + id: cache-blender + uses: actions/cache@v3 + with: + path: /tmp/blender.tar.xz + key: blender-4.2.0 + + - name: Install Blender 4.2 + run: | + BLENDER_VERSION="4.2.4" + BLENDER_FILE="blender-${BLENDER_VERSION}-linux-x64.tar.xz" + + if [ ! -f /tmp/blender.tar.xz ]; then + # Download Blender + wget "https://download.blender.org/release/Blender4.2/${BLENDER_FILE}" -O /tmp/blender.tar.xz + fi + + # Extract Blender + tar -xf /tmp/blender.tar.xz + + # Move Blender to /usr/local and create symlink + sudo mv "blender-${BLENDER_VERSION}-linux-x64" /usr/local/blender + sudo ln -s /usr/local/blender/blender /usr/local/bin/blender + + # Verify installation + blender --background --version + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + # Add any additional dependencies your script needs here + # pip install -r requirements.txt + + - name: Build & install Blender plugin + run: | + blender --command extension build --output-filepath iiif_blender.zip + blender --command extension install-file --enable --repo user_default iiif_blender.zip + + - name: Run tests + id: run-tests + run: | + echo "Running manifest tests..." + bash tests/run_tests.sh + + - name: Report test results + if: always() + run: | + echo "Test execution completed" + if [ "${{ steps.run-tests.outcome }}" == "failure" ]; then + echo "❌ Tests failed" + exit 1 + else + echo "✅ Tests passed" + fi diff --git a/run_blender_with_plugin.py b/run_blender_with_plugin.py new file mode 100644 index 0000000..8112ca9 --- /dev/null +++ b/run_blender_with_plugin.py @@ -0,0 +1,154 @@ +import json +import sys +import os +from typing import Any, Dict, List, Tuple, Callable +import subprocess +import bpy +import difflib + +# Ensure the script receives the correct number of arguments +if len(sys.argv) < 4: + print( + "Usage: blender --background --python run_blender_with_plugin.py -- " + ) + sys.exit(1) + +# Get the input and output manifest file paths from the command line arguments +input_manifest = sys.argv[sys.argv.index("--") + 1] +output_manifest = input_manifest.replace(".json", "_export.json") + +context = bpy.context +if context is None: + print("Failed to get the Blender context") + sys.exit(1) + + +def get_extension_id(): + try: + manifest_path = os.path.join(os.path.dirname(__file__), "blender_manifest.toml") + with open(manifest_path, "r") as f: + for line in f: + line = line.strip() + if line.startswith("id = "): + # Extract the value between quotes + return line.split("=")[1].strip().strip('"').strip("'") + except Exception as e: + print(f"Error reading blender_manifest.toml: {e}") + sys.exit(1) + print("Could not find id in blender_manifest.toml") + sys.exit(1) + + +# Load the plugin +needle = get_extension_id() +ext_name = None +for key in context.preferences.addons.keys(): + if needle in key: + ext_name = key + break + +if not ext_name: + print("Failed to find the plugin") + sys.exit(1) + +bpy.ops.preferences.addon_enable(module=ext_name) + +if ext_name not in context.preferences.addons: + print("Failed to load the plugin") + sys.exit(1) + + +def safe_delete(file_path): + try: + # Check if file exists before attempting deletion + if os.path.exists(file_path): + os.remove(file_path) + print(f"File {file_path} has been deleted successfully") + else: + print(f"File {file_path} does not exist") + except Exception as e: + print(f"Error occurred while deleting file: {e}") + + +RED: Callable[[str], str] = lambda text: f"\u001b[31m{text}\033\u001b[0m" +GREEN: Callable[[str], str] = lambda text: f"\u001b[32m{text}\033\u001b[0m" + + +def get_edits_string(old: str, new: str) -> Tuple[str, bool]: + result = "" + + lines = difflib.ndiff(old.splitlines(keepends=True), new.splitlines(keepends=True)) + + has_changes = False + + for line in lines: + line = line.rstrip() + if line.startswith("+"): + has_changes = True + result += GREEN(line) + "\n" + elif line.startswith("-"): + has_changes = True + result += RED(line) + "\n" + elif line.startswith("?"): + continue + else: + result += line + "\n" + + return (result, has_changes) + + +def get_json_diff(file1_path: str, file2_path: str) -> Tuple[str, bool]: + with open(file1_path) as f1: + json1 = json.load(f1) + with open(file2_path) as f2: + json2 = json.load(f2) + return get_edits_string( + json.dumps(json1, indent=2, sort_keys=True), + json.dumps(json2, indent=2, sort_keys=True), + ) + + +def get_indent(level): + return " " * level + + +def print_object_hierarchy(obj, level): + indent = get_indent(level) + print(f"{indent}- {obj.name} ({obj.type})") + + for child in obj.children: + print_object_hierarchy(child, level + 1) + + +def print_collection_hierarchy(collection, level=0): + indent = get_indent(level) + print(f"{indent}{collection.name} (Collection):") + + for obj in collection.objects: + if not obj.parent: + print_object_hierarchy(obj, level + 1) + + for child_col in collection.children: + print_collection_hierarchy(child_col, level + 1) + + +bpy.ops.import_scene.iiif_manifest(filepath=input_manifest) + +print("\n\nPrinting scene hierarchy:") +print_collection_hierarchy(bpy.context.scene.collection) +print("\n\n") + +bpy.ops.export_scene.iiif_manifest(filepath=output_manifest) + +differences, has_changes = get_json_diff(input_manifest, output_manifest) + +# Delete output manifest +safe_delete(output_manifest) + +if has_changes: + print("Imported manifest differs from exported manifest:") + print(differences) + sys.exit(1) +else: + print("Imported manifest equals exported manifest") + sys.exit(0) diff --git a/tests/blender_scenes/.gitkeep b/tests/blender_scenes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/iiif_manifests/0101_model_origin.json b/tests/iiif_manifests/0101_model_origin.json new file mode 100644 index 0000000..dfd3856 --- /dev/null +++ b/tests/iiif_manifests/0101_model_origin.json @@ -0,0 +1,36 @@ +{ + "@context": "http://iiif.io/api/presentation/4/context.json", + "id": "https://example.org/iiif/3d/model_origin.json", + "type": "Manifest", + "label": { "en": ["Single Model"] }, + "summary": { + "en": [ + "Viewer should render the model at the scene origin, and then viewer should add default lighting and camera" + ] + }, + "items": [ + { + "id": "https://example.org/iiif/scene1/page/p1/1", + "type": "Scene", + "label": { "en": ["A Scene"] }, + "items": [ + { + "id": "https://example.org/iiif/scene1/page/p1/1", + "type": "AnnotationPage", + "items": [ + { + "id": "https://example.org/iiif/3d/anno1", + "type": "Annotation", + "motivation": ["painting"], + "body": { + "id": "https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb", + "type": "Model" + }, + "target": "https://example.org/iiif/scene1/page/p1/1" + } + ] + } + ] + } + ] +} diff --git a/tests/iiif_manifests/0201_perspective_camera.json b/tests/iiif_manifests/0201_perspective_camera.json new file mode 100644 index 0000000..50e11fc --- /dev/null +++ b/tests/iiif_manifests/0201_perspective_camera.json @@ -0,0 +1,43 @@ +{ + "@context": "http://iiif.io/api/presentation/4/context.json", + "id": "https://example.org/iiif/3d/model_origin.json", + "type": "Manifest", + "label": { "en": ["Model with Explicit Perspective Camera"] }, + "summary": { "en": ["Viewer should render the model at the scene origin, and the camera at the scene origin facing -Z, then add default lighting"] }, + "items": [ + { + "id": "https://example.org/iiif/scene1/page/p1/1", + "type": "Scene", + "label": { "en": ["Scene with Model and Camera"] }, + "items": [ + { + "id": "https://example.org/iiif/scene1/page/p1/1", + "type": "AnnotationPage", + "items": [ + { + "id": "https://example.org/iiif/3d/anno1", + "type": "Annotation", + "motivation": ["painting"], + "body": { + "id": "https://raw.githubusercontent.com/IIIF/3d/main/assets/astronaut/astronaut.glb", + "type": "Model" + }, + "target": "https://example.org/iiif/scene1/page/p1/1" + }, + { + "id": "https://example.org/iiif/3d/anno1", + "type": "Annotation", + "motivation": ["painting"], + "body": { + "id": "https://example.org/iiif/3d/cameras/1", + "type": "PerspectiveCamera", + "label": {"en": ["Perspective Camera 1"]} + }, + "target": "https://example.org/iiif/scene1/page/p1/1" + } + ] + } + ] + } + ] +} diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100644 index 0000000..a3c1db2 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +FAILED_TESTS=0 + +run_test() { + local command="$1" + local expected_code="${2:-0}" + + eval "$command" > /dev/null 2>&1 + local actual_code=$? + + if [ "$actual_code" -eq "$expected_code" ]; then + echo "✅ Test passed: '$command' (Exit code: $actual_code)" + return 0 + else + echo "❌ Test failed: '$command'" + echo "ℹ️ Expected exit code: $expected_code" + echo "ℹ️ Actual exit code: $actual_code" + return 1 + fi +} + +for manifest in tests/iiif_manifests/*.json; do + echo "ℹ️ Testing manifest: $manifest" + if ! run_test "blender --background --python run_blender_with_plugin.py -- '$manifest'"; then + ((FAILED_TESTS++)) + fi +done + +if [ $FAILED_TESTS -gt 0 ]; then + echo "❌ $FAILED_TESTS test(s) failed" + exit 1 +else + echo "✅ All tests passed" + exit 0 +fi