feat: add Gemini-powered birdseye rendering
This commit is contained in:
142
tests/test_birdseye_generator.py
Normal file
142
tests/test_birdseye_generator.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from civilplan_mcp.tools.project_parser import parse_project
|
||||
|
||||
|
||||
def _sample_project_spec(tmp_path) -> dict:
|
||||
project_spec = parse_project(
|
||||
description="도로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 화성시 2026~2028"
|
||||
)
|
||||
project_spec["project_id"] = "PRJ-20260404-001"
|
||||
project_spec["output_dir"] = str(tmp_path)
|
||||
return project_spec
|
||||
|
||||
|
||||
def test_generate_birdseye_view_returns_both_images(monkeypatch, tmp_path) -> None:
|
||||
from civilplan_mcp.tools import birdseye_generator
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_image.side_effect = lambda **kwargs: {
|
||||
"status": "success",
|
||||
"path": kwargs["output_path"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"get_settings",
|
||||
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"GeminiImageService",
|
||||
lambda **kwargs: mock_service,
|
||||
)
|
||||
|
||||
result = birdseye_generator.generate_birdseye_view(
|
||||
project_summary="경기도 화성시 도로 신설 890m",
|
||||
project_spec=_sample_project_spec(tmp_path),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["birdseye_view"]["status"] == "success"
|
||||
assert result["perspective_view"]["status"] == "success"
|
||||
assert mock_service.generate_image.call_count == 2
|
||||
assert "validity_disclaimer" in result
|
||||
|
||||
|
||||
def test_generate_birdseye_view_uses_svg_reference(monkeypatch, tmp_path) -> None:
|
||||
from civilplan_mcp.tools import birdseye_generator
|
||||
|
||||
reference_path = tmp_path / "reference.png"
|
||||
reference_path.write_bytes(b"png")
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_image.return_value = {"status": "success", "path": str(tmp_path / "out.png")}
|
||||
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"get_settings",
|
||||
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"GeminiImageService",
|
||||
lambda **kwargs: mock_service,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"svg_to_png",
|
||||
lambda svg_content, output_path: str(reference_path),
|
||||
)
|
||||
|
||||
result = birdseye_generator.generate_birdseye_view(
|
||||
project_summary="경기도 화성시 도로 신설 890m",
|
||||
project_spec=_sample_project_spec(tmp_path),
|
||||
svg_drawing="<svg></svg>",
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
for call in mock_service.generate_image.call_args_list:
|
||||
assert call.kwargs["reference_image_path"] == str(reference_path)
|
||||
|
||||
|
||||
def test_generate_birdseye_view_requires_gemini_key(monkeypatch, tmp_path) -> None:
|
||||
from civilplan_mcp.tools import birdseye_generator
|
||||
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"get_settings",
|
||||
lambda: MagicMock(gemini_api_key="", output_dir=tmp_path),
|
||||
)
|
||||
|
||||
result = birdseye_generator.generate_birdseye_view(
|
||||
project_summary="경기도 화성시 도로 신설 890m",
|
||||
project_spec=_sample_project_spec(tmp_path),
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "GEMINI_API_KEY" in result["error"]
|
||||
|
||||
|
||||
def test_generate_birdseye_view_returns_partial_if_one_view_fails(monkeypatch, tmp_path) -> None:
|
||||
from civilplan_mcp.tools import birdseye_generator
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.generate_image.side_effect = [
|
||||
{"status": "success", "path": str(tmp_path / "birdseye.png")},
|
||||
{"status": "error", "error": "rate limit"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"get_settings",
|
||||
lambda: MagicMock(gemini_api_key="test-key", output_dir=tmp_path),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
birdseye_generator,
|
||||
"GeminiImageService",
|
||||
lambda **kwargs: mock_service,
|
||||
)
|
||||
|
||||
result = birdseye_generator.generate_birdseye_view(
|
||||
project_summary="경기도 화성시 도로 신설 890m",
|
||||
project_spec=_sample_project_spec(tmp_path),
|
||||
)
|
||||
|
||||
assert result["status"] == "partial"
|
||||
assert result["birdseye_view"]["status"] == "success"
|
||||
assert result["perspective_view"]["status"] == "error"
|
||||
|
||||
|
||||
def test_domain_to_project_type_mapping() -> None:
|
||||
from civilplan_mcp.tools.birdseye_generator import _domain_to_project_type
|
||||
|
||||
assert _domain_to_project_type("토목_도로") == "road"
|
||||
assert _domain_to_project_type("건축") == "building"
|
||||
assert _domain_to_project_type("토목_상하수도") == "water"
|
||||
assert _domain_to_project_type("토목_하천") == "river"
|
||||
assert _domain_to_project_type("조경") == "landscape"
|
||||
assert _domain_to_project_type("복합") == "mixed"
|
||||
assert _domain_to_project_type("unknown") == "mixed"
|
||||
51
tests/test_birdseye_templates.py
Normal file
51
tests/test_birdseye_templates.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_build_birdseye_prompt_for_road() -> None:
|
||||
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||
|
||||
prompt = build_prompt(
|
||||
view_type="birdseye",
|
||||
project_type="road",
|
||||
project_summary="경기도 지방도 890m, 폭 6m, 2차선 아스팔트 포장 도로",
|
||||
details={"length_m": 890, "width_m": 6, "lanes": 2, "pavement": "아스팔트"},
|
||||
)
|
||||
|
||||
assert "bird's-eye view" in prompt.lower()
|
||||
assert "890" in prompt
|
||||
assert "road" in prompt.lower()
|
||||
|
||||
|
||||
def test_build_perspective_prompt_for_building() -> None:
|
||||
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||
|
||||
prompt = build_prompt(
|
||||
view_type="perspective",
|
||||
project_type="building",
|
||||
project_summary="서울시 강남구 5층 오피스 빌딩",
|
||||
details={"floors": 5, "use": "오피스"},
|
||||
)
|
||||
|
||||
assert "perspective" in prompt.lower()
|
||||
assert "building" in prompt.lower()
|
||||
|
||||
|
||||
def test_all_project_types_have_templates() -> None:
|
||||
from civilplan_mcp.prompts.birdseye_templates import DOMAIN_PROMPTS
|
||||
|
||||
assert set(DOMAIN_PROMPTS) == {"road", "building", "water", "river", "landscape", "mixed"}
|
||||
|
||||
|
||||
def test_unknown_project_type_falls_back_to_mixed() -> None:
|
||||
from civilplan_mcp.prompts.birdseye_templates import build_prompt
|
||||
|
||||
prompt = build_prompt(
|
||||
view_type="birdseye",
|
||||
project_type="unknown",
|
||||
project_summary="복합 개발 프로젝트",
|
||||
details={},
|
||||
)
|
||||
|
||||
assert isinstance(prompt, str)
|
||||
assert len(prompt) > 50
|
||||
assert "comprehensive development site" in prompt.lower()
|
||||
@@ -45,15 +45,18 @@ def test_get_settings_uses_secure_store_when_env_missing(tmp_path: Path, monkeyp
|
||||
lambda path: {
|
||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||
"VWORLD_API_KEY": "secure-vworld-key",
|
||||
"GEMINI_API_KEY": "secure-gemini-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
|
||||
settings = config.get_settings()
|
||||
|
||||
assert settings.data_go_kr_api_key == "secure-data-key"
|
||||
assert settings.vworld_api_key == "secure-vworld-key"
|
||||
assert settings.gemini_api_key == "secure-gemini-key"
|
||||
|
||||
|
||||
def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -65,12 +68,37 @@ def test_get_settings_prefers_env_values_over_secure_store(tmp_path: Path, monke
|
||||
lambda path: {
|
||||
"DATA_GO_KR_API_KEY": "secure-data-key",
|
||||
"VWORLD_API_KEY": "secure-vworld-key",
|
||||
"GEMINI_API_KEY": "secure-gemini-key",
|
||||
},
|
||||
)
|
||||
monkeypatch.setenv("DATA_GO_KR_API_KEY", "env-data-key")
|
||||
monkeypatch.setenv("VWORLD_API_KEY", "env-vworld-key")
|
||||
monkeypatch.setenv("GEMINI_API_KEY", "env-gemini-key")
|
||||
|
||||
settings = config.get_settings()
|
||||
|
||||
assert settings.data_go_kr_api_key == "env-data-key"
|
||||
assert settings.vworld_api_key == "env-vworld-key"
|
||||
assert settings.gemini_api_key == "env-gemini-key"
|
||||
|
||||
|
||||
def test_settings_has_gemini_api_key(monkeypatch) -> None:
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
|
||||
settings = config.Settings()
|
||||
|
||||
assert hasattr(settings, "gemini_api_key")
|
||||
assert settings.gemini_api_key == ""
|
||||
|
||||
|
||||
def test_check_api_keys_includes_gemini_when_missing(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(config, "BASE_DIR", tmp_path)
|
||||
monkeypatch.setattr(config, "load_local_env", lambda: None)
|
||||
monkeypatch.setattr(config, "_load_secure_api_keys", lambda path: {})
|
||||
monkeypatch.delenv("DATA_GO_KR_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VWORLD_API_KEY", raising=False)
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
|
||||
missing = config.check_api_keys()
|
||||
|
||||
assert "GEMINI_API_KEY" in missing
|
||||
|
||||
122
tests/test_gemini_image.py
Normal file
122
tests/test_gemini_image.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
def test_service_defaults_to_nano_banana_pro_model() -> None:
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
service = GeminiImageService(api_key="test-key", client=MagicMock())
|
||||
|
||||
assert service.api_key == "test-key"
|
||||
assert service.model == "gemini-3-pro-image-preview"
|
||||
|
||||
|
||||
def test_service_requires_sdk_or_client() -> None:
|
||||
from civilplan_mcp.services import gemini_image
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
original_genai = gemini_image.genai
|
||||
gemini_image.genai = None
|
||||
try:
|
||||
with pytest.raises(RuntimeError):
|
||||
GeminiImageService(api_key="test-key")
|
||||
finally:
|
||||
gemini_image.genai = original_genai
|
||||
|
||||
|
||||
def test_generate_image_calls_api_and_saves_output(tmp_path) -> None:
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_image = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.inline_data = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.as_image.return_value = mock_image
|
||||
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||
|
||||
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||
output_path = tmp_path / "generated.png"
|
||||
|
||||
result = service.generate_image(
|
||||
prompt="Generate a bird's-eye render of a Korean road project.",
|
||||
output_path=str(output_path),
|
||||
aspect_ratio="16:9",
|
||||
image_size="2K",
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["path"] == str(output_path)
|
||||
mock_image.save.assert_called_once_with(str(output_path))
|
||||
call_kwargs = mock_client.models.generate_content.call_args.kwargs
|
||||
assert call_kwargs["model"] == "gemini-3-pro-image-preview"
|
||||
assert call_kwargs["contents"] == ["Generate a bird's-eye render of a Korean road project."]
|
||||
assert call_kwargs["config"] is not None
|
||||
|
||||
|
||||
def test_generate_image_with_reference_includes_image_content(tmp_path) -> None:
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
reference_path = tmp_path / "reference.png"
|
||||
PILImage.new("RGB", (8, 8), "gray").save(reference_path)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_image = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.inline_data = MagicMock()
|
||||
mock_part.text = None
|
||||
mock_part.as_image.return_value = mock_image
|
||||
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||
|
||||
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||
output_path = tmp_path / "generated.png"
|
||||
|
||||
result = service.generate_image(
|
||||
prompt="Generate a road perspective render.",
|
||||
output_path=str(output_path),
|
||||
reference_image_path=str(reference_path),
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
contents = mock_client.models.generate_content.call_args.kwargs["contents"]
|
||||
assert len(contents) == 2
|
||||
assert contents[0] == "Generate a road perspective render."
|
||||
|
||||
|
||||
def test_generate_image_returns_error_on_api_failure(tmp_path) -> None:
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.generate_content.side_effect = RuntimeError("API error")
|
||||
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||
|
||||
result = service.generate_image(
|
||||
prompt="Generate a river project render.",
|
||||
output_path=str(tmp_path / "generated.png"),
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "API error" in result["error"]
|
||||
|
||||
|
||||
def test_generate_image_returns_error_when_response_has_no_image(tmp_path) -> None:
|
||||
from civilplan_mcp.services.gemini_image import GeminiImageService
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_part = MagicMock()
|
||||
mock_part.inline_data = None
|
||||
mock_part.text = "No image available."
|
||||
mock_client.models.generate_content.return_value = MagicMock(parts=[mock_part])
|
||||
service = GeminiImageService(api_key="test-key", client=mock_client)
|
||||
|
||||
result = service.generate_image(
|
||||
prompt="Generate a building render.",
|
||||
output_path=str(tmp_path / "generated.png"),
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "no image" in result["error"].lower()
|
||||
@@ -11,14 +11,15 @@ def test_build_server_config_defaults() -> None:
|
||||
assert config["path"] == "/mcp"
|
||||
|
||||
|
||||
def test_server_registers_all_19_tools() -> None:
|
||||
def test_server_registers_all_20_tools() -> None:
|
||||
app = build_mcp()
|
||||
tools = asyncio.run(app.list_tools())
|
||||
names = {tool.name for tool in tools}
|
||||
|
||||
assert len(names) == 19
|
||||
assert len(names) == 20
|
||||
assert "civilplan_parse_project" in names
|
||||
assert "civilplan_generate_dxf_drawing" in names
|
||||
assert "civilplan_generate_birdseye_view" in names
|
||||
|
||||
|
||||
def test_read_tools_have_read_only_hint() -> None:
|
||||
|
||||
26
tests/test_setup_keys.py
Normal file
26
tests/test_setup_keys.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from civilplan_mcp import setup_keys
|
||||
|
||||
|
||||
def test_main_prompts_and_saves_gemini_api_key(monkeypatch, tmp_path: Path) -> None:
|
||||
prompted_values = iter(["data-key", "vworld-key", "gemini-key"])
|
||||
saved_payload: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr(setup_keys, "_prompt_value", lambda name, current="": next(prompted_values))
|
||||
monkeypatch.setattr(
|
||||
setup_keys,
|
||||
"save_api_keys",
|
||||
lambda payload: saved_payload.update(payload) or tmp_path / "api-keys.dpapi.json",
|
||||
)
|
||||
|
||||
exit_code = setup_keys.main([])
|
||||
|
||||
assert exit_code == 0
|
||||
assert saved_payload == {
|
||||
"DATA_GO_KR_API_KEY": "data-key",
|
||||
"VWORLD_API_KEY": "vworld-key",
|
||||
"GEMINI_API_KEY": "gemini-key",
|
||||
}
|
||||
@@ -3,7 +3,7 @@ from civilplan_mcp.config import Settings, get_settings
|
||||
|
||||
|
||||
def test_package_version_present() -> None:
|
||||
assert __version__ == "1.0.0"
|
||||
assert __version__ == "2.0.0"
|
||||
|
||||
|
||||
def test_settings_have_expected_defaults() -> None:
|
||||
|
||||
Reference in New Issue
Block a user