From 0ff53602559b8762ed6f0cb748ff653c9c86622b Mon Sep 17 00:00:00 2001 From: DankerMu Date: Mon, 26 Jan 2026 21:09:23 +0800 Subject: [PATCH 1/2] feat: add image aspect ratio setting --- backend/controllers/settings_controller.py | 75 ++++++++++++++++++- .../tests/integration/test_api_full_flow.py | 42 +++++------ backend/tests/unit/test_api_settings.py | 49 ++++++++++++ frontend/src/pages/Settings.tsx | 18 +++++ pyproject.toml | 1 + uv.lock | 2 + 6 files changed, 164 insertions(+), 23 deletions(-) create mode 100644 backend/tests/unit/test_api_settings.py diff --git a/backend/controllers/settings_controller.py b/backend/controllers/settings_controller.py index 158592f2..30965cd3 100644 --- a/backend/controllers/settings_controller.py +++ b/backend/controllers/settings_controller.py @@ -1,8 +1,10 @@ """Settings Controller - handles application settings endpoints""" import logging +import re import shutil import tempfile +from math import gcd from pathlib import Path from datetime import datetime, timezone from contextlib import contextmanager @@ -23,6 +25,71 @@ "settings", __name__, url_prefix="/api/settings" ) +# Accept custom ratios but guard against obviously invalid input. +# Prefer common ratios used by common image models. +_COMMON_IMAGE_ASPECT_RATIOS = { + "1:1", + "3:4", + "4:3", + "16:9", + "9:16", + "21:9", + "3:2", + "2:3", + "4:5", + "5:4", +} + +_ASPECT_RATIO_PATTERN = re.compile(r"^\d+:\d+$") +_ASPECT_RATIO_MIN = 0.2 +_ASPECT_RATIO_MAX = 5.0 + + +def _normalize_image_aspect_ratio(raw_value): + """ + Normalize and validate aspect ratio input. + + - Accepts "W:H" where W/H are positive integers. + - Reduces by gcd (e.g., "1920:1080" -> "16:9"). + - Rejects obviously invalid or extreme ratios. + """ + if raw_value is None: + raise ValueError("Image aspect ratio is required") + + value = str(raw_value).strip() + if value == "": + raise ValueError("Image aspect ratio is required") + + if not _ASPECT_RATIO_PATTERN.fullmatch(value): + raise ValueError( + "Image aspect ratio must match \\d+:\\d+ (e.g., 16:9, 4:3, 1:1)" + ) + + width, height = (int(part) for part in value.split(":", 1)) + if width <= 0 or height <= 0: + raise ValueError("Image aspect ratio must be positive integers (e.g., 16:9)") + + divisor = gcd(width, height) + width //= divisor + height //= divisor + + ratio_value = width / height + if ratio_value < _ASPECT_RATIO_MIN or ratio_value > _ASPECT_RATIO_MAX: + raise ValueError( + f"Image aspect ratio must be between {_ASPECT_RATIO_MIN:.1f} and {_ASPECT_RATIO_MAX:.1f} (e.g., 16:9)" + ) + + normalized = f"{width}:{height}" + if len(normalized) > 10: + raise ValueError("Image aspect ratio is too long") + + if normalized not in _COMMON_IMAGE_ASPECT_RATIOS: + logger.info( + f"Non-standard image_aspect_ratio provided: {normalized} (allowed, but may not be supported by all providers)" + ) + + return normalized + @contextmanager def temporary_settings_override(settings_override: dict): @@ -180,8 +247,12 @@ def update_settings(): settings.image_resolution = resolution if "image_aspect_ratio" in data: - aspect_ratio = data["image_aspect_ratio"] - settings.image_aspect_ratio = aspect_ratio + try: + settings.image_aspect_ratio = _normalize_image_aspect_ratio( + data["image_aspect_ratio"] + ) + except ValueError as e: + return bad_request(str(e)) # Update worker configuration if "max_description_workers" in data: diff --git a/backend/tests/integration/test_api_full_flow.py b/backend/tests/integration/test_api_full_flow.py index 78b6b1cd..d71638fd 100644 --- a/backend/tests/integration/test_api_full_flow.py +++ b/backend/tests/integration/test_api_full_flow.py @@ -16,7 +16,7 @@ """ import pytest -import requests +import httpx import time import os import io @@ -26,7 +26,8 @@ # Skip these tests if service is not running (for backend-integration-test stage) pytestmark = pytest.mark.skipif( - os.environ.get('SKIP_SERVICE_TESTS', '').lower() == 'true', + os.environ.get('SKIP_SERVICE_TESTS', '').lower() == 'true' + or os.environ.get('TESTING', '').lower() == 'true', reason="Skipping tests that require running backend service" ) @@ -45,9 +46,9 @@ def wait_for_project_status(project_id: str, expected_status: str, timeout: int while time.time() - start_time < timeout: try: - response = requests.get(f"{BASE_URL}/api/projects/{project_id}", timeout=10) + response = httpx.get(f"{BASE_URL}/api/projects/{project_id}", timeout=10) - if not response.ok: + if not response.is_success: consecutive_errors += 1 if consecutive_errors >= max_consecutive_errors: raise Exception(f"Failed to get project status after {max_consecutive_errors} consecutive errors") @@ -97,12 +98,12 @@ def wait_for_task_completion(project_id: str, task_id: str, timeout: int = 120): while time.time() - start_time < timeout: try: - response = requests.get( + response = httpx.get( f"{BASE_URL}/api/projects/{project_id}/tasks/{task_id}", timeout=10 ) - if not response.ok: + if not response.is_success: consecutive_errors += 1 if consecutive_errors >= max_consecutive_errors: raise Exception(f"Failed to get task status after {max_consecutive_errors} consecutive errors") @@ -155,7 +156,7 @@ def register_project(pid): # Cleanup for pid in created_project_ids: try: - requests.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10) + httpx.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10) print(f"✓ Cleaned up project: {pid}") except Exception as e: print(f"Failed to cleanup project {pid}: {e}") @@ -183,7 +184,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 1: Create project print('📝 Step 1: Creating project...') - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects", json={ 'creation_type': 'idea', @@ -209,7 +210,7 @@ def test_api_full_flow_create_to_export(self, project_id): template_img.save(img_bytes, format='PNG') img_bytes.seek(0) - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects/{pid}/template", files={'template_image': ('template.png', img_bytes, 'image/png')}, timeout=30 @@ -222,7 +223,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 2: Generate outline print('📋 Step 2: Triggering outline generation...') - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects/{pid}/generate/outline", json={}, timeout=30 @@ -238,7 +239,7 @@ def test_api_full_flow_create_to_export(self, project_id): wait_for_project_status(pid, 'OUTLINE_GENERATED', timeout=API_TIMEOUT) # Verify pages were created - response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) + response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) data = response.json() pages = data['data']['pages'] @@ -248,7 +249,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 4: Generate descriptions print('✍️ Step 4: Starting to generate page descriptions...') - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects/{pid}/generate/descriptions", json={}, timeout=30 @@ -268,7 +269,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 5: Generate images print('🎨 Step 5: Starting to generate page images...') - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects/{pid}/generate/images", json={ 'use_template': True, # Use the uploaded template @@ -291,7 +292,7 @@ def test_api_full_flow_create_to_export(self, project_id): print('✓ All page images generated\n') # Verify all pages have images - response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) + response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) data = response.json() pages = data['data'].get('pages', []) @@ -305,7 +306,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 6: Export PPT print('📦 Step 6: Exporting PPT file...') - response = requests.get( + response = httpx.get( f"{BASE_URL}/api/projects/{pid}/export/pptx?filename=integration-test.pptx", timeout=60 ) @@ -321,7 +322,7 @@ def test_api_full_flow_create_to_export(self, project_id): # Step 7: Verify PPT can be downloaded print('📥 Step 7: Verifying PPT file can be downloaded...') download_url = data['data']['download_url'] - response = requests.get(f"{BASE_URL}{download_url}", timeout=30) + response = httpx.get(f"{BASE_URL}{download_url}", timeout=30) assert response.status_code == 200 @@ -355,7 +356,7 @@ def test_quick_api_flow_no_ai(self): print('\n🏃 Quick API flow test (skip AI generation)\n') # Create project - response = requests.post( + response = httpx.post( f"{BASE_URL}/api/projects", json={ 'creation_type': 'idea', @@ -370,19 +371,18 @@ def test_quick_api_flow_no_ai(self): print(f"✓ Project created: {pid}") # Get project info - response = requests.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) + response = httpx.get(f"{BASE_URL}/api/projects/{pid}", timeout=10) assert response.status_code == 200 print('✓ Project query successful') # List all projects - response = requests.get(f"{BASE_URL}/api/projects", timeout=10) + response = httpx.get(f"{BASE_URL}/api/projects", timeout=10) assert response.status_code == 200 data = response.json() assert 'projects' in data['data'] print(f"✓ Project list query successful, total {len(data['data']['projects'])} projects") # Delete project - response = requests.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10) + response = httpx.delete(f"{BASE_URL}/api/projects/{pid}", timeout=10) assert response.status_code == 200 print('✓ Project deleted successfully\n') - diff --git a/backend/tests/unit/test_api_settings.py b/backend/tests/unit/test_api_settings.py new file mode 100644 index 00000000..bbe0ddd2 --- /dev/null +++ b/backend/tests/unit/test_api_settings.py @@ -0,0 +1,49 @@ +""" +Settings API unit tests +""" + +import pytest + +from conftest import assert_success_response, assert_error_response + + +class TestSettingsAspectRatio: + """Image aspect ratio settings tests""" + + def test_get_settings_includes_image_aspect_ratio(self, client): + response = client.get("/api/settings") + data = assert_success_response(response) + assert data["data"]["image_aspect_ratio"] == "16:9" + + def test_update_settings_persists_image_aspect_ratio(self, client): + response = client.put("/api/settings", json={"image_aspect_ratio": "4:3"}) + data = assert_success_response(response) + assert data["data"]["image_aspect_ratio"] == "4:3" + assert client.application.config["DEFAULT_ASPECT_RATIO"] == "4:3" + + response = client.get("/api/settings") + data = assert_success_response(response) + assert data["data"]["image_aspect_ratio"] == "4:3" + + def test_update_settings_normalizes_image_aspect_ratio(self, client): + response = client.put("/api/settings", json={"image_aspect_ratio": "1920:1080"}) + data = assert_success_response(response) + assert data["data"]["image_aspect_ratio"] == "16:9" + + @pytest.mark.parametrize( + "value", + [ + "", + None, + "16x9", + "16:0", + "0:16", + "abc", + "1:1000", + "1000:1", + ], + ) + def test_update_settings_rejects_invalid_image_aspect_ratio(self, client, value): + response = client.put("/api/settings", json={"image_aspect_ratio": value}) + assert_error_response(response, expected_status=400) + diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index f24d160f..23bbb9a5 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -158,6 +158,24 @@ const settingsSections: SectionConfig[] = [ { value: '4K', label: '4K (4096px)' }, ], }, + { + key: 'image_aspect_ratio', + label: '画面比例', + type: 'select', + description: '生成图片时使用的宽高比(影响构图与画布尺寸)', + options: [ + { value: '16:9', label: '16:9(横屏)' }, + { value: '4:3', label: '4:3(传统横屏)' }, + { value: '21:9', label: '21:9(超宽屏)' }, + { value: '1:1', label: '1:1(方形)' }, + { value: '3:4', label: '3:4(竖屏)' }, + { value: '9:16', label: '9:16(竖屏)' }, + { value: '3:2', label: '3:2(横屏)' }, + { value: '2:3', label: '2:3(竖屏)' }, + { value: '5:4', label: '5:4(横屏)' }, + { value: '4:5', label: '4:5(竖屏)' }, + ], + }, ], }, { diff --git a/pyproject.toml b/pyproject.toml index 20f3f105..0800b848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "python-pptx>=1.0.0", "python-dotenv>=1.0.1", "reportlab>=4.1.0", + "requests>=2.0.0", "werkzeug>=3.0.1", "markitdown[all]", "tenacity>=9.0.0", diff --git a/uv.lock b/uv.lock index 98ff4742..3283b026 100644 --- a/uv.lock +++ b/uv.lock @@ -177,6 +177,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "python-pptx" }, { name = "reportlab" }, + { name = "requests" }, { name = "tenacity" }, { name = "werkzeug" }, ] @@ -215,6 +216,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-pptx", specifier = ">=1.0.0" }, { name = "reportlab", specifier = ">=4.1.0" }, + { name = "requests", specifier = ">=2.0.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "werkzeug", specifier = ">=3.0.1" }, ] From f0f8923fed6eb7332612855d27f44921ac5dfa03 Mon Sep 17 00:00:00 2001 From: DankerMu Date: Mon, 26 Jan 2026 21:30:56 +0800 Subject: [PATCH 2/2] fix: gate service tests with SKIP_SERVICE_TESTS --- backend/tests/conftest.py | 2 +- backend/tests/integration/README.md | 7 +++---- backend/tests/integration/test_api_full_flow.py | 3 +-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6fda702b..cf98a50c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,6 +17,7 @@ # 设置测试环境变量 - 必须在导入app之前设置 os.environ['TESTING'] = 'true' +os.environ.setdefault('SKIP_SERVICE_TESTS', 'true') os.environ['USE_MOCK_AI'] = 'true' # 标记使用mock AI服务 os.environ['GOOGLE_API_KEY'] = os.environ.get('GOOGLE_API_KEY', 'mock-api-key-for-testing') os.environ['FLASK_ENV'] = 'testing' @@ -178,4 +179,3 @@ def assert_error_response(response, expected_status=None): assert data is not None assert data.get('success') is False or 'error' in data return data - diff --git a/backend/tests/integration/README.md b/backend/tests/integration/README.md index 4fe26fbb..801e4b58 100644 --- a/backend/tests/integration/README.md +++ b/backend/tests/integration/README.md @@ -22,7 +22,7 @@ uv run pytest tests/integration/test_full_workflow.py -v ### 2. Real Service 测试(需要运行服务) **文件**: `test_api_full_flow.py` -这些测试使用 `requests` 库直接调用 HTTP 端点,需要真实的后端服务运行。 +这些测试使用 `httpx` 库直接调用 HTTP 端点,需要真实的后端服务运行。 **特点**: - ⏱️ 较慢(需要真实 HTTP 请求) @@ -90,7 +90,7 @@ GOOGLE_API_KEY: | `unit` | 单元测试 | 测试单个函数或方法 | | `integration` | 集成测试 | 测试多个组件交互 | | `slow` | 慢速测试 | 需要 AI API 调用的测试 | -| `requires_service` | 需要运行服务 | 使用 requests 调用 HTTP 端点 | +| `requires_service` | 需要运行服务 | 使用 httpx 调用 HTTP 端点 | | `mock` | 使用 mock | 不调用真实外部服务 | | `docker` | Docker 环境测试 | 需要 Docker 环境 | @@ -163,7 +163,7 @@ pytest tests/integration -v -m "not requires_service" @pytest.mark.integration @pytest.mark.requires_service def test_real_api_call(self): - response = requests.post('http://localhost:5000/api/projects', ...) + response = httpx.post('http://localhost:5000/api/projects', ...) ``` 3. **环境检查**: @@ -174,4 +174,3 @@ pytest tests/integration -v -m "not requires_service" **更新日期**: 2025-12-22 **维护者**: Banana Slides Team - diff --git a/backend/tests/integration/test_api_full_flow.py b/backend/tests/integration/test_api_full_flow.py index d71638fd..fb77e625 100644 --- a/backend/tests/integration/test_api_full_flow.py +++ b/backend/tests/integration/test_api_full_flow.py @@ -26,8 +26,7 @@ # Skip these tests if service is not running (for backend-integration-test stage) pytestmark = pytest.mark.skipif( - os.environ.get('SKIP_SERVICE_TESTS', '').lower() == 'true' - or os.environ.get('TESTING', '').lower() == 'true', + os.environ.get('SKIP_SERVICE_TESTS', '').lower() == 'true', reason="Skipping tests that require running backend service" )