Initial CivilPlan MCP implementation
This commit is contained in:
17
tests/test_base.py
Normal file
17
tests/test_base.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from civilplan_mcp.models import ProjectDomain
|
||||
from civilplan_mcp.tools._base import wrap_response
|
||||
|
||||
|
||||
def test_project_domain_contains_expected_values() -> None:
|
||||
assert ProjectDomain.건축.value == "건축"
|
||||
assert ProjectDomain.토목_도로.value == "토목_도로"
|
||||
assert ProjectDomain.복합.value == "복합"
|
||||
|
||||
|
||||
def test_wrap_response_adds_required_disclaimers() -> None:
|
||||
wrapped = wrap_response({"status": "ok"}, ProjectDomain.토목_도로)
|
||||
|
||||
assert wrapped["status"] == "ok"
|
||||
assert "domain_note" in wrapped
|
||||
assert "validity_disclaimer" in wrapped
|
||||
assert wrapped["data_as_of"] == "2026년 4월 기준"
|
||||
39
tests/test_data_files.py
Normal file
39
tests/test_data_files.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
from civilplan_mcp.config import get_settings
|
||||
from civilplan_mcp.db.bootstrap import bootstrap_database, load_json_data
|
||||
|
||||
|
||||
def test_required_seed_files_exist() -> None:
|
||||
data_dir = get_settings().data_dir
|
||||
required = [
|
||||
"unit_prices_2026.json",
|
||||
"legal_procedures.json",
|
||||
"region_factors.json",
|
||||
"road_standards.json",
|
||||
"guidelines_catalog.json",
|
||||
"association_prices_catalog.json",
|
||||
"waste_disposal_prices_2025.json",
|
||||
"indirect_cost_rates.json",
|
||||
"supervision_rates.json",
|
||||
"rental_benchmark.json",
|
||||
"price_update_calendar.json",
|
||||
"data_validity_warnings.json",
|
||||
]
|
||||
|
||||
for name in required:
|
||||
assert (data_dir / name).exists(), name
|
||||
|
||||
|
||||
def test_load_json_data_reads_seed_file() -> None:
|
||||
data = load_json_data("region_factors.json")
|
||||
|
||||
assert "경기도" in data
|
||||
|
||||
|
||||
def test_bootstrap_database_creates_sqlite_file(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "civilplan.db"
|
||||
|
||||
bootstrap_database(db_path)
|
||||
|
||||
assert db_path.exists()
|
||||
36
tests/test_extended_tools.py
Normal file
36
tests/test_extended_tools.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from civilplan_mcp.models import ProjectDomain
|
||||
from civilplan_mcp.tools.bid_type_selector import select_bid_type
|
||||
from civilplan_mcp.tools.guideline_fetcher import fetch_guideline_summary
|
||||
from civilplan_mcp.tools.guideline_resolver import get_applicable_guidelines
|
||||
from civilplan_mcp.tools.waste_estimator import estimate_waste_disposal
|
||||
|
||||
|
||||
def test_get_applicable_guidelines_returns_matches() -> None:
|
||||
result = get_applicable_guidelines(
|
||||
domain=ProjectDomain.토목_도로,
|
||||
procedure_ids=["PER-01"],
|
||||
project_type="도로",
|
||||
)
|
||||
|
||||
assert result["guidelines"]
|
||||
|
||||
|
||||
def test_fetch_guideline_summary_returns_catalog_summary() -> None:
|
||||
result = fetch_guideline_summary(guideline_id="GL-001")
|
||||
|
||||
assert result["summary"]["id"] == "GL-001"
|
||||
|
||||
|
||||
def test_select_bid_type_chooses_comprehensive_for_large_cost() -> None:
|
||||
result = select_bid_type(total_cost_billion=350.0, domain=ProjectDomain.건축)
|
||||
|
||||
assert result["recommended_type"] == "종합심사낙찰제"
|
||||
|
||||
|
||||
def test_estimate_waste_disposal_returns_cost() -> None:
|
||||
result = estimate_waste_disposal(
|
||||
project_type="도로",
|
||||
waste_items={"폐콘크리트": 100, "폐아스팔트콘크리트": 50},
|
||||
)
|
||||
|
||||
assert result["summary"]["total_cost_won"] > 0
|
||||
88
tests/test_generators.py
Normal file
88
tests/test_generators.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from civilplan_mcp.tools.boq_generator import generate_boq_excel
|
||||
from civilplan_mcp.tools.doc_generator import generate_investment_doc
|
||||
from civilplan_mcp.tools.drawing_generator import generate_svg_drawing
|
||||
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
|
||||
from civilplan_mcp.tools.project_parser import parse_project
|
||||
from civilplan_mcp.tools.unit_price_query import get_unit_prices
|
||||
|
||||
|
||||
def _sample_inputs(tmp_path: Path) -> tuple[dict, dict, dict]:
|
||||
project_spec = parse_project(
|
||||
description="소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028"
|
||||
)
|
||||
project_spec["output_dir"] = str(tmp_path)
|
||||
quantities = estimate_quantities(
|
||||
road_class="소로",
|
||||
length_m=890,
|
||||
width_m=6.0,
|
||||
terrain="구릉",
|
||||
pavement_type="아스콘",
|
||||
include_water_supply=True,
|
||||
include_sewage=True,
|
||||
include_retaining_wall=True,
|
||||
include_bridge=False,
|
||||
bridge_length_m=0.0,
|
||||
)
|
||||
unit_prices = get_unit_prices(region="경기도", year=2026)
|
||||
return project_spec, quantities, unit_prices
|
||||
|
||||
|
||||
def test_generate_boq_excel_creates_workbook(tmp_path: Path) -> None:
|
||||
project_spec, quantities, unit_prices = _sample_inputs(tmp_path)
|
||||
|
||||
result = generate_boq_excel(
|
||||
project_name="소로 개설(신설) 공사",
|
||||
project_spec=project_spec,
|
||||
quantities=quantities,
|
||||
unit_prices=unit_prices,
|
||||
region="경기도",
|
||||
year=2026,
|
||||
output_filename="boq.xlsx",
|
||||
)
|
||||
|
||||
workbook = load_workbook(result["file_path"], data_only=False)
|
||||
assert "사업내역서(BOQ)" in workbook.sheetnames
|
||||
|
||||
|
||||
def test_generate_investment_doc_creates_docx(tmp_path: Path) -> None:
|
||||
project_spec, quantities, unit_prices = _sample_inputs(tmp_path)
|
||||
boq = generate_boq_excel(
|
||||
project_name="소로 개설(신설) 공사",
|
||||
project_spec=project_spec,
|
||||
quantities=quantities,
|
||||
unit_prices=unit_prices,
|
||||
region="경기도",
|
||||
year=2026,
|
||||
output_filename="boq_for_doc.xlsx",
|
||||
)
|
||||
|
||||
result = generate_investment_doc(
|
||||
project_name="소로 개설(신설) 공사",
|
||||
project_spec=project_spec,
|
||||
quantities=quantities,
|
||||
legal_procedures={"summary": {"total_procedures": 3}, "phases": {}},
|
||||
boq_summary=boq["summary"],
|
||||
requester="22B Labs",
|
||||
output_filename="investment.docx",
|
||||
)
|
||||
|
||||
assert Path(result["file_path"]).exists()
|
||||
|
||||
|
||||
def test_generate_svg_drawing_creates_svg(tmp_path: Path) -> None:
|
||||
project_spec, quantities, _ = _sample_inputs(tmp_path)
|
||||
|
||||
result = generate_svg_drawing(
|
||||
drawing_type="횡단면도",
|
||||
project_spec=project_spec,
|
||||
quantities=quantities,
|
||||
scale="1:200",
|
||||
output_filename="road.svg",
|
||||
)
|
||||
|
||||
content = Path(result["file_path"]).read_text(encoding="utf-8")
|
||||
assert "<svg" in content
|
||||
20
tests/test_impacts.py
Normal file
20
tests/test_impacts.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from civilplan_mcp.tools.impact_evaluator import evaluate_impact_assessments
|
||||
|
||||
|
||||
def test_evaluate_impact_assessments_flags_borderline_disaster_review() -> None:
|
||||
result = evaluate_impact_assessments(
|
||||
domain="토목_도로",
|
||||
project_type="도로",
|
||||
road_length_m=890,
|
||||
development_area_m2=5340,
|
||||
total_cost_billion=10.67,
|
||||
building_floor_area_m2=None,
|
||||
housing_units=None,
|
||||
is_urban_area=True,
|
||||
near_cultural_heritage=False,
|
||||
near_river=False,
|
||||
near_protected_area=False,
|
||||
)
|
||||
|
||||
target = next(item for item in result["evaluations"] if item["name"] == "재해영향평가")
|
||||
assert target["result"] == "BORDERLINE"
|
||||
113
tests/test_land_info_and_benchmark_fallbacks.py
Normal file
113
tests/test_land_info_and_benchmark_fallbacks.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from civilplan_mcp.config import Settings
|
||||
from civilplan_mcp.tools import benchmark_validator, land_info_query
|
||||
|
||||
|
||||
def _json_response(url: str, payload: dict, *, status_code: int = 200) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code,
|
||||
request=httpx.Request("GET", url),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
|
||||
def test_query_land_info_uses_eum_land_use_endpoint(monkeypatch, tmp_path) -> None:
|
||||
settings = Settings(vworld_api_key="test-key", data_go_kr_api_key="public-key", data_dir=tmp_path)
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_get(url: str, *, params=None, timeout=None, headers=None): # type: ignore[no-untyped-def]
|
||||
calls.append(url)
|
||||
if url == land_info_query.VWORLD_ADDRESS_URL:
|
||||
return _json_response(
|
||||
url,
|
||||
{
|
||||
"response": {
|
||||
"status": "OK",
|
||||
"result": {
|
||||
"point": {"x": "127.061", "y": "37.821"},
|
||||
"items": [
|
||||
{
|
||||
"id": "4163011600101230000",
|
||||
"address": {"parcel": "경기도 양주시 덕계동 123"},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
if url == land_info_query.VWORLD_DATA_URL:
|
||||
return _json_response(
|
||||
url,
|
||||
{
|
||||
"response": {
|
||||
"status": "OK",
|
||||
"result": {
|
||||
"featureCollection": {
|
||||
"features": [
|
||||
{
|
||||
"properties": {
|
||||
"area": "320.5",
|
||||
"jimok": "대",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
if url == land_info_query.EUM_LAND_USE_URL:
|
||||
return _json_response(
|
||||
url,
|
||||
{
|
||||
"landUseInfo": {
|
||||
"useDistrict": "제2종일반주거지역",
|
||||
"district2": "고도지구",
|
||||
"bcr": "60",
|
||||
"far": "200",
|
||||
"heightLimit": "16",
|
||||
}
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
monkeypatch.setattr(land_info_query, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(land_info_query.httpx, "get", fake_get)
|
||||
|
||||
result = land_info_query.query_land_info(address="경기도 양주시 덕계동 123", pnu=None)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["zoning"]["use_district"] == "제2종일반주거지역"
|
||||
assert result["zoning"]["district_2"] == "고도지구"
|
||||
assert result["zoning"]["bcr_pct"] == 60
|
||||
assert result["zoning"]["far_pct"] == 200
|
||||
assert land_info_query.EUM_LAND_USE_URL in calls
|
||||
assert "https://api.vworld.kr/req/wfs" not in calls
|
||||
|
||||
|
||||
def test_validate_against_benchmark_reports_backend_fallback_on_502(monkeypatch) -> None:
|
||||
settings = Settings(data_go_kr_api_key="test-key")
|
||||
|
||||
def fake_get(url: str, *, params=None, timeout=None): # type: ignore[no-untyped-def]
|
||||
return httpx.Response(
|
||||
502,
|
||||
request=httpx.Request("GET", url),
|
||||
text="backend unavailable",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(benchmark_validator, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(benchmark_validator.httpx, "get", fake_get)
|
||||
|
||||
result = benchmark_validator.validate_against_benchmark(
|
||||
project_type="도로_포장",
|
||||
road_length_m=890,
|
||||
floor_area_m2=None,
|
||||
region="경기도",
|
||||
our_estimate_won=1_067_000_000,
|
||||
)
|
||||
|
||||
assert result["benchmark"]["api_status"] == "fallback"
|
||||
assert "나라장터 직접 검색" in result["benchmark"]["availability_note"]
|
||||
76
tests/test_land_info_ingestion_and_updater.py
Normal file
76
tests/test_land_info_ingestion_and_updater.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
from civilplan_mcp.config import Settings
|
||||
from civilplan_mcp.tools.land_info_query import (
|
||||
_read_land_price_from_files,
|
||||
extract_land_use_html_properties,
|
||||
)
|
||||
from civilplan_mcp.updater import wage_updater
|
||||
|
||||
|
||||
def test_extract_land_use_html_properties_reads_regulation_table() -> None:
|
||||
html = """
|
||||
<div class="tbl04 mb">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>제2종일반주거지역</td>
|
||||
<td>60%</td>
|
||||
<td>200%</td>
|
||||
</tr>
|
||||
<tr class="center"><td colspan="3" class="center bg">건폐율</td></tr>
|
||||
<tr><td colspan="3" class="left" id="PopupG_pop"><p>최대 건폐율 60%</p></td></tr>
|
||||
<tr class="center"><td colspan="3" class="center bg">용적률</td></tr>
|
||||
<tr><td colspan="3" class="left" id="PopupY_pop"><p>최대 용적률 200%</p></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
parsed = extract_land_use_html_properties(html)
|
||||
|
||||
assert parsed["usedistrictnm"] == "제2종일반주거지역"
|
||||
assert parsed["bcr"] == "60"
|
||||
assert parsed["far"] == "200"
|
||||
|
||||
|
||||
def test_read_land_price_from_zip_file(tmp_path: Path, monkeypatch) -> None:
|
||||
land_price_dir = tmp_path / "land_prices"
|
||||
land_price_dir.mkdir()
|
||||
archive_path = land_price_dir / "seoul_land_prices.zip"
|
||||
|
||||
buffer = BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w") as archive:
|
||||
archive.writestr(
|
||||
"prices.csv",
|
||||
"PNU,LANDPRICE\n1111010100100010000,1250000\n",
|
||||
)
|
||||
archive_path.write_bytes(buffer.getvalue())
|
||||
|
||||
settings = Settings(data_dir=tmp_path)
|
||||
monkeypatch.setattr("civilplan_mcp.tools.land_info_query.get_settings", lambda: settings)
|
||||
|
||||
price = _read_land_price_from_files("1111010100100010000")
|
||||
|
||||
assert price == {"individual_m2_won": 1250000, "source": "seoul_land_prices.zip:prices.csv"}
|
||||
|
||||
|
||||
def test_update_wage_rates_creates_flag_and_log_on_parse_failure(tmp_path: Path, monkeypatch) -> None:
|
||||
settings = Settings(data_dir=tmp_path)
|
||||
|
||||
monkeypatch.setattr(wage_updater, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(
|
||||
wage_updater,
|
||||
"fetch_source_text",
|
||||
lambda url: "<html><body>No structured wage table here</body></html>",
|
||||
)
|
||||
|
||||
result = wage_updater.update_wage_rates(period="상반기")
|
||||
|
||||
assert result["status"] == "pending_manual_review"
|
||||
assert (tmp_path / ".update_required_wage").exists()
|
||||
assert (tmp_path / "update_log.json").exists()
|
||||
34
tests/test_legal.py
Normal file
34
tests/test_legal.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from civilplan_mcp.models import ProjectDomain
|
||||
from civilplan_mcp.tools.legal_procedures import get_legal_procedures
|
||||
from civilplan_mcp.tools.phase_checklist import get_phase_checklist
|
||||
|
||||
|
||||
def test_get_legal_procedures_returns_domain_specific_items() -> None:
|
||||
result = get_legal_procedures(
|
||||
domain=ProjectDomain.토목_도로,
|
||||
project_type="도로",
|
||||
total_cost_billion=10.67,
|
||||
road_length_m=890,
|
||||
development_area_m2=5340,
|
||||
region="경기도",
|
||||
has_farmland=False,
|
||||
has_forest=False,
|
||||
has_river=False,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
assert result["summary"]["total_procedures"] >= 2
|
||||
assert "인허가" in result["phases"]
|
||||
|
||||
|
||||
def test_get_phase_checklist_returns_preparing_message_for_landscape() -> None:
|
||||
result = get_phase_checklist(
|
||||
domain=ProjectDomain.조경,
|
||||
phase="기획",
|
||||
project_type="조경",
|
||||
total_cost_billion=3.0,
|
||||
has_building=False,
|
||||
has_bridge=False,
|
||||
)
|
||||
|
||||
assert result["status"] == "not_ready"
|
||||
21
tests/test_parser.py
Normal file
21
tests/test_parser.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from civilplan_mcp.models import ProjectDomain
|
||||
from civilplan_mcp.tools.project_parser import parse_project
|
||||
|
||||
|
||||
def test_parse_project_extracts_core_road_fields() -> None:
|
||||
result = parse_project(
|
||||
description="소로 신설 L=890m B=6m 아스콘 2차선 상하수도 경기도 둔턱지역 2026~2028"
|
||||
)
|
||||
|
||||
assert result["road"]["length_m"] == 890
|
||||
assert result["road"]["width_m"] == 6.0
|
||||
assert result["road"]["lanes"] == 2
|
||||
assert result["region"] == "경기도"
|
||||
assert result["domain"] == ProjectDomain.토목_도로.value
|
||||
|
||||
|
||||
def test_parse_project_marks_composite_domain_warning() -> None:
|
||||
result = parse_project(description="복지관 신축 및 진입도로 개설")
|
||||
|
||||
assert result["domain"] == ProjectDomain.복합.value
|
||||
assert "domain_warning" in result
|
||||
27
tests/test_quantities.py
Normal file
27
tests/test_quantities.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
|
||||
from civilplan_mcp.tools.unit_price_query import get_unit_prices
|
||||
|
||||
|
||||
def test_estimate_quantities_returns_expected_pavement_area() -> None:
|
||||
result = estimate_quantities(
|
||||
road_class="소로",
|
||||
length_m=890,
|
||||
width_m=6.0,
|
||||
terrain="구릉",
|
||||
pavement_type="아스콘",
|
||||
include_water_supply=True,
|
||||
include_sewage=True,
|
||||
include_retaining_wall=True,
|
||||
include_bridge=False,
|
||||
bridge_length_m=0.0,
|
||||
)
|
||||
|
||||
assert result["road_spec"]["pavement_area_m2"] == 5340.0
|
||||
assert result["quantities"]["포장"]["아스콘표층_t"] > 0
|
||||
|
||||
|
||||
def test_get_unit_prices_applies_region_factor() -> None:
|
||||
result = get_unit_prices(category="포장", item_name="아스콘", region="경기도", year=2026)
|
||||
|
||||
assert result["results"]
|
||||
assert result["results"][0]["adjusted_price"] > result["results"][0]["base_price"]
|
||||
29
tests/test_server_registration.py
Normal file
29
tests/test_server_registration.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import asyncio
|
||||
|
||||
from civilplan_mcp.server import build_mcp, build_server_config
|
||||
|
||||
|
||||
def test_build_server_config_defaults() -> None:
|
||||
config = build_server_config()
|
||||
|
||||
assert config["host"] == "127.0.0.1"
|
||||
assert config["port"] == 8765
|
||||
assert config["path"] == "/mcp"
|
||||
|
||||
|
||||
def test_server_registers_all_19_tools() -> None:
|
||||
app = build_mcp()
|
||||
tools = asyncio.run(app.list_tools())
|
||||
names = {tool.name for tool in tools}
|
||||
|
||||
assert len(names) == 19
|
||||
assert "civilplan_parse_project" in names
|
||||
assert "civilplan_generate_dxf_drawing" in names
|
||||
|
||||
|
||||
def test_read_tools_have_read_only_hint() -> None:
|
||||
app = build_mcp()
|
||||
tools = {tool.name: tool for tool in asyncio.run(app.list_tools())}
|
||||
|
||||
assert tools["civilplan_parse_project"].annotations.readOnlyHint is True
|
||||
assert tools["civilplan_generate_boq_excel"].annotations.readOnlyHint is None
|
||||
18
tests/test_smoke.py
Normal file
18
tests/test_smoke.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from civilplan_mcp import __version__
|
||||
from civilplan_mcp.config import Settings, get_settings
|
||||
|
||||
|
||||
def test_package_version_present() -> None:
|
||||
assert __version__ == "1.0.0"
|
||||
|
||||
|
||||
def test_settings_have_expected_defaults() -> None:
|
||||
settings = Settings()
|
||||
|
||||
assert settings.host == "127.0.0.1"
|
||||
assert settings.port == 8765
|
||||
assert settings.http_path == "/mcp"
|
||||
|
||||
|
||||
def test_get_settings_is_cached() -> None:
|
||||
assert get_settings() is get_settings()
|
||||
23
tests/test_updater.py
Normal file
23
tests/test_updater.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from civilplan_mcp.updater.scheduler import build_scheduler
|
||||
from civilplan_mcp.updater.wage_updater import check_update_flags, flag_manual_update_required
|
||||
|
||||
|
||||
def test_build_scheduler_registers_expected_jobs() -> None:
|
||||
scheduler = build_scheduler(start=False)
|
||||
|
||||
assert {job.id for job in scheduler.get_jobs()} == {
|
||||
"wage_h1",
|
||||
"waste_annual",
|
||||
"standard_h1",
|
||||
"standard_h2",
|
||||
"wage_h2",
|
||||
}
|
||||
|
||||
|
||||
def test_flag_manual_update_required_creates_flag(tmp_path: Path) -> None:
|
||||
flag_manual_update_required("wage", "manual review needed", data_dir=tmp_path)
|
||||
|
||||
warnings = check_update_flags(data_dir=tmp_path)
|
||||
assert warnings
|
||||
159
tests/test_v11_tools.py
Normal file
159
tests/test_v11_tools.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
|
||||
from civilplan_mcp.config import Settings
|
||||
from civilplan_mcp.tools.benchmark_validator import validate_against_benchmark
|
||||
from civilplan_mcp.tools.budget_report_generator import generate_budget_report
|
||||
from civilplan_mcp.tools.dxf_generator import generate_dxf_drawing
|
||||
from civilplan_mcp.tools.feasibility_analyzer import analyze_feasibility
|
||||
from civilplan_mcp.tools.land_info_query import (
|
||||
build_land_use_bbox_params,
|
||||
extract_address_result,
|
||||
extract_feature_properties,
|
||||
query_land_info,
|
||||
)
|
||||
from civilplan_mcp.tools.project_parser import parse_project
|
||||
from civilplan_mcp.tools.quantity_estimator import estimate_quantities
|
||||
|
||||
|
||||
def test_query_land_info_returns_graceful_message_without_api_keys() -> None:
|
||||
result = query_land_info(address="경기도 양주시 덕계동 123", pnu=None)
|
||||
|
||||
assert result["status"] == "disabled"
|
||||
|
||||
|
||||
def test_settings_keep_public_data_key_for_nara_usage() -> None:
|
||||
settings = Settings(data_go_kr_api_key="abc")
|
||||
|
||||
assert settings.data_go_kr_api_key == "abc"
|
||||
|
||||
|
||||
def test_extract_address_result_reads_vworld_shape() -> None:
|
||||
payload = {
|
||||
"response": {
|
||||
"status": "OK",
|
||||
"result": {
|
||||
"point": {"x": "127.061", "y": "37.821"},
|
||||
"items": [
|
||||
{
|
||||
"id": "4163010100101230000",
|
||||
"address": {"parcel": "경기도 양주시 덕계동 123"}
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
parsed = extract_address_result(payload)
|
||||
assert parsed["pnu"] == "4163010100101230000"
|
||||
assert parsed["x"] == 127.061
|
||||
|
||||
|
||||
def test_extract_feature_properties_returns_first_feature() -> None:
|
||||
payload = {
|
||||
"response": {
|
||||
"status": "OK",
|
||||
"result": {
|
||||
"featureCollection": {
|
||||
"features": [
|
||||
{"properties": {"pnu": "4163010100101230000", "jimok": "대"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
parsed = extract_feature_properties(payload)
|
||||
assert parsed["jimok"] == "대"
|
||||
|
||||
|
||||
def test_build_land_use_bbox_params_uses_wfs_bbox_pattern() -> None:
|
||||
params = build_land_use_bbox_params(127.0, 37.0, "test-key")
|
||||
|
||||
assert params["SERVICE"] == "WFS"
|
||||
assert params["REQUEST"] == "GetFeature"
|
||||
assert params["TYPENAME"] == "lt_c_lhblpn"
|
||||
assert params["SRSNAME"] == "EPSG:4326"
|
||||
assert params["KEY"] == "test-key"
|
||||
assert params["OUTPUTFORMAT"] == "application/json"
|
||||
assert params["BBOX"] == "126.9995,36.9995,127.0005,37.0005,EPSG:4326"
|
||||
|
||||
|
||||
def test_analyze_feasibility_returns_positive_cost_structure() -> None:
|
||||
result = analyze_feasibility(
|
||||
land_area_m2=600,
|
||||
land_price_per_m2=850000,
|
||||
land_price_multiplier=1.0,
|
||||
construction_cost_total=890000000,
|
||||
other_costs_million=50,
|
||||
revenue_type="임대",
|
||||
building_floor_area_m2=720,
|
||||
sale_price_per_m2=None,
|
||||
monthly_rent_per_m2=16000,
|
||||
vacancy_rate_pct=10.0,
|
||||
operating_expense_pct=20.0,
|
||||
equity_ratio_pct=30.0,
|
||||
loan_rate_pct=5.5,
|
||||
loan_term_years=10,
|
||||
construction_months=24,
|
||||
sale_months=12,
|
||||
)
|
||||
|
||||
assert result["cost_structure"]["total_investment"] > 0
|
||||
assert "irr_pct" in result["returns"]
|
||||
|
||||
|
||||
def test_validate_against_benchmark_includes_bid_warning() -> None:
|
||||
result = validate_against_benchmark(
|
||||
project_type="소로_도로",
|
||||
road_length_m=890,
|
||||
floor_area_m2=None,
|
||||
region="경기도",
|
||||
our_estimate_won=1067000000,
|
||||
)
|
||||
|
||||
assert "낙찰가 ≠ 사업비" in result["bid_rate_reference"]["경고"]
|
||||
|
||||
|
||||
def test_generate_budget_report_creates_docx(tmp_path: Path) -> None:
|
||||
project_data = parse_project(description="복지관 신축 2026~2028 경기도")
|
||||
project_data["output_dir"] = str(tmp_path)
|
||||
result = generate_budget_report(
|
||||
report_type="예산편성요구서",
|
||||
project_data=project_data,
|
||||
boq_summary={"total_cost": 1067000000, "direct_cost": 900422000, "indirect_cost": 166578000},
|
||||
department="도로과",
|
||||
requester="22B Labs",
|
||||
output_filename="budget.docx",
|
||||
)
|
||||
|
||||
assert Path(result["file_path"]).exists()
|
||||
|
||||
|
||||
def test_generate_dxf_drawing_creates_dxf(tmp_path: Path) -> None:
|
||||
project_spec = parse_project(description="소로 신설 L=890m B=6m 경기도")
|
||||
project_spec["output_dir"] = str(tmp_path)
|
||||
quantities = estimate_quantities(
|
||||
road_class="소로",
|
||||
length_m=890,
|
||||
width_m=6.0,
|
||||
terrain="평지",
|
||||
pavement_type="아스콘",
|
||||
include_water_supply=False,
|
||||
include_sewage=False,
|
||||
include_retaining_wall=False,
|
||||
include_bridge=False,
|
||||
bridge_length_m=0.0,
|
||||
)
|
||||
|
||||
result = generate_dxf_drawing(
|
||||
drawing_type="횡단면도",
|
||||
project_spec=project_spec,
|
||||
quantities=quantities,
|
||||
scale="1:200",
|
||||
output_filename="road.dxf",
|
||||
)
|
||||
|
||||
doc = ezdxf.readfile(result["file_path"])
|
||||
assert doc is not None
|
||||
Reference in New Issue
Block a user