This commit is contained in:
2026-07-05 21:27:23 +09:00
parent 23d907265a
commit 3abc2edba6
83 changed files with 10351 additions and 1217 deletions
+110
View File
@@ -0,0 +1,110 @@
import tempfile
import unittest
from pathlib import Path
import laspy
import numpy as np
import rasterio
from pyproj import CRS
from rasterio.transform import from_origin
from B03_FileInput.B03_FileInput_Engine_Analyze import (
analyze_input_metadata,
analyze_las_metadata,
analyze_prj_metadata,
analyze_tfw_metadata,
analyze_tif_metadata,
)
class AnalyzeLasMetadataTest(unittest.TestCase):
def test_analyze_las_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "sample.las"
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
las.x = np.array([100.0, 102.0])
las.y = np.array([200.0, 204.0])
las.z = np.array([10.0, 14.0])
las.classification = np.array([2, 5], dtype=np.uint8)
las.write(source)
metadata = analyze_las_metadata(source)
self.assertEqual(metadata["file"], "sample.las")
self.assertEqual(metadata["point_count"], 2)
self.assertEqual(metadata["bounds"]["x"], [100.0, 102.0])
self.assertEqual(metadata["bounds"]["y"], [200.0, 204.0])
self.assertEqual(metadata["bounds"]["z"], [10.0, 14.0])
self.assertEqual(metadata["classification_summary"], {"2": 1, "5": 1})
def test_analyze_prj_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "result.prj"
source.write_text(CRS.from_epsg(5179).to_wkt(), encoding="utf-8")
metadata = analyze_prj_metadata(source)
self.assertEqual(metadata["file"], "result.prj")
self.assertEqual(metadata["epsg"], 5179)
self.assertTrue(metadata["is_valid"])
source.write_text("invalid wkt", encoding="utf-8")
invalid_metadata = analyze_prj_metadata(source)
self.assertFalse(invalid_metadata["is_valid"])
self.assertIn("error", invalid_metadata)
def test_analyze_tfw_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "result.tfw"
source.write_text("0.5\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
metadata = analyze_tfw_metadata(source)
self.assertTrue(metadata["is_valid"])
self.assertEqual(metadata["pixel_size_x"], 0.5)
self.assertEqual(metadata["pixel_size_y"], -0.5)
self.assertEqual(metadata["origin_x"], 100.0)
self.assertEqual(metadata["origin_y"], 200.0)
source.write_text("nan\n0\n0\n-0.5\n100\n200\n", encoding="utf-8")
with self.assertRaises(ValueError):
analyze_tfw_metadata(source)
def test_analyze_tif_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "result.tif"
with rasterio.open(
source,
mode="w",
driver="GTiff",
width=3,
height=2,
count=1,
dtype="float32",
crs="EPSG:5179",
transform=from_origin(100, 200, 2, 2),
nodata=-9999,
) as dataset:
dataset.write(np.ones((1, 2, 3), dtype=np.float32))
metadata = analyze_tif_metadata(source)
self.assertEqual(metadata["file"], "result.tif")
self.assertEqual(metadata["width"], 3)
self.assertEqual(metadata["height"], 2)
self.assertEqual(metadata["count"], 1)
self.assertEqual(metadata["epsg"], 5179)
self.assertEqual(metadata["resolution"], [2.0, 2.0])
self.assertEqual(metadata["likely_type"], "dem")
def test_analyze_input_metadata(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "drawing.dxf"
source.write_bytes(b"0\nSECTION\n")
metadata = analyze_input_metadata(source)
self.assertEqual(
metadata,
{"file": "drawing.dxf", "extension": "dxf", "size_bytes": 10},
)
+58
View File
@@ -0,0 +1,58 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from B03_FileInput.B03_FileInput_Engine import (
resolve_upload_destination,
save_upload_stream,
)
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
class ResolveUploadDestinationTest(unittest.TestCase):
def test_resolve_upload_destination(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
descriptor = FileUploadDescriptor(
original_filename="cloud_merged.LAS",
size_bytes=1024,
)
destination = resolve_upload_destination(temporary_directory, descriptor)
self.assertEqual(
destination,
Path(temporary_directory).resolve()
/ "B03_FileInput"
/ "input"
/ "las"
/ "cloud_merged.LAS",
)
self.assertTrue(destination.parent.is_dir())
class SaveUploadStreamTest(unittest.IsolatedAsyncioTestCase):
async def test_save_upload_stream(self) -> None:
class FakeUpload:
def __init__(self, chunks: list[bytes]) -> None:
self.chunks = iter(chunks)
async def read(self, _size: int) -> bytes:
return next(self.chunks, b"")
with tempfile.TemporaryDirectory() as temporary_directory:
destination = Path(temporary_directory) / "cloud.las"
upload = FakeUpload([b"abc", b"def"])
written_bytes = await save_upload_stream(upload, destination) # type: ignore[arg-type]
self.assertEqual(written_bytes, 6)
self.assertEqual(destination.read_bytes(), b"abcdef")
self.assertEqual(list(destination.parent.glob("*.upload")), [])
with patch("B03_FileInput.B03_FileInput_Engine.UPLOAD_MAX_MB", 0):
with self.assertRaises(ValueError):
await save_upload_stream( # type: ignore[arg-type]
FakeUpload([b"too-large"]),
destination,
)
+100
View File
@@ -0,0 +1,100 @@
import json
import unittest
from typing import Any
from uuid import UUID
from B03_FileInput.B03_FileInput_Repository import (
create_input_file,
get_project_storage_relative_path,
)
class FakeCursor:
"""asyncmy 커서 흉내 (async context manager + execute/fetchone/lastrowid)."""
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
self.query = ""
self.arguments: tuple[Any, ...] = ()
self.lastrowid = lastrowid
self._row = row
async def __aenter__(self) -> "FakeCursor":
return self
async def __aexit__(self, *_exc: Any) -> None:
return None
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
self.query = query
self.arguments = arguments or ()
async def fetchone(self) -> tuple[Any, ...] | None:
return self._row
class FakeConnection:
"""asyncmy 커넥션 흉내 (cursor() 팩토리)."""
def __init__(self, cursor: FakeCursor) -> None:
self._cursor = cursor
def cursor(self) -> FakeCursor:
return self._cursor
class CreateInputFileTest(unittest.IsolatedAsyncioTestCase):
async def test_create_input_file(self) -> None:
cursor = FakeCursor(lastrowid=17)
connection = FakeConnection(cursor)
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
metadata = {"point_count": 2, "crs": "EPSG:5179"}
input_file_id = await create_input_file( # type: ignore[arg-type]
connection,
project_id=project_id,
file_type="las",
original_filename="cloud.las",
relative_path="B03_FileInput/input/las/cloud.las",
file_size_bytes=1024 * 1024,
upload_by=3,
crs_epsg=5179,
metadata=metadata,
)
self.assertEqual(input_file_id, 17)
self.assertIn("INSERT INTO input_files", cursor.query)
self.assertEqual(cursor.arguments[0], str(project_id))
self.assertEqual(cursor.arguments[3], "B03_FileInput/input/las/cloud.las")
self.assertEqual(cursor.arguments[4], 1.0)
self.assertEqual(json.loads(cursor.arguments[7]), metadata)
with self.assertRaises(ValueError):
await create_input_file( # type: ignore[arg-type]
connection,
project_id=project_id,
file_type="las",
original_filename="cloud.las",
relative_path="../outside/cloud.las",
file_size_bytes=1,
upload_by=None,
crs_epsg=None,
metadata={},
)
async def test_get_project_storage_relative_path(self) -> None:
project_id = UUID("550e8400-e29b-41d4-a716-446655440000")
connection = FakeConnection(FakeCursor(row=("storage/company/user/project-id",)))
relative_path = await get_project_storage_relative_path( # type: ignore[arg-type]
connection,
project_id,
)
self.assertEqual(relative_path, "storage/company/user/project-id")
for row in (None, ("../outside",), ("/absolute/path",)):
with self.subTest(row=row):
with self.assertRaises((LookupError, ValueError)):
await get_project_storage_relative_path( # type: ignore[arg-type]
FakeConnection(FakeCursor(row=row)),
project_id,
)
+122
View File
@@ -0,0 +1,122 @@
import io
import tempfile
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch
from uuid import UUID
import laspy
import numpy as np
from B03_FileInput.B03_FileInput_Router import upload_project_files
from B03_FileInput.B03_FileInput_Schema import FileUploadResponse
class UploadProjectFilesTest(unittest.IsolatedAsyncioTestCase):
async def test_upload_project_files(self) -> None:
class AsyncContext:
def __init__(self, value: Any = None) -> None:
self.value = value
async def __aenter__(self) -> Any:
return self.value
async def __aexit__(self, *_args: Any) -> None:
return None
class FakeCursor:
def __init__(self, connection: "FakeConnection") -> None:
self.connection = connection
self.lastrowid = 0
async def __aenter__(self) -> "FakeCursor":
return self
async def __aexit__(self, *_args: Any) -> None:
return None
async def execute(self, query: str, _arguments: Any = None) -> None:
if query.strip().upper().startswith("SELECT"):
self._row: tuple[Any, ...] | None = ("storage/company/user/project-id",)
else:
self.lastrowid = self.connection.next_id
self.connection.next_id += 1
self._row = None
async def fetchone(self) -> tuple[Any, ...] | None:
return getattr(self, "_row", None)
class FakeConnection:
def __init__(self) -> None:
self.next_id = 1
self.committed = False
self.rolled_back = False
def cursor(self) -> FakeCursor:
return FakeCursor(self)
async def begin(self) -> None:
return None
async def commit(self) -> None:
self.committed = True
async def rollback(self) -> None:
self.rolled_back = True
class FakePool:
def __init__(self, connection: FakeConnection) -> None:
self.connection = connection
def acquire(self) -> AsyncContext:
return AsyncContext(self.connection)
class FakeUpload:
def __init__(self, filename: str, content: bytes) -> None:
self.filename = filename
self.size = len(content)
self.file = io.BytesIO(content)
self.closed = False
async def read(self, size: int) -> bytes:
return self.file.read(size)
async def close(self) -> None:
self.file.close()
self.closed = True
with tempfile.TemporaryDirectory() as temporary_directory:
project_root = Path(temporary_directory) / "project"
project_root.mkdir()
source = Path(temporary_directory) / "source.las"
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
las.x = np.array([1.0])
las.y = np.array([2.0])
las.z = np.array([3.0])
las.write(source)
upload = FakeUpload("cloud.las", source.read_bytes())
pool = FakePool(FakeConnection())
with (
patch("B03_FileInput.B03_FileInput_Router.get_db_pool", return_value=pool),
patch(
"B03_FileInput.B03_FileInput_Router.resolve_stored_project_path",
return_value=str(project_root),
),
):
response = await upload_project_files( # type: ignore[arg-type]
UUID("550e8400-e29b-41d4-a716-446655440000"),
[upload],
)
self.assertIsInstance(response, FileUploadResponse)
assert isinstance(response, FileUploadResponse)
self.assertEqual(len(response.files), 1)
self.assertEqual(response.files[0].input_file_id, 1)
self.assertTrue(
(project_root / "B03_FileInput" / "input" / "las" / "cloud.las").is_file()
)
self.assertTrue((project_root / "B03_FileInput" / "metadata.json").is_file())
self.assertTrue((project_root / "workflow.json").is_file())
self.assertTrue(upload.closed)
+30
View File
@@ -0,0 +1,30 @@
import unittest
from pydantic import ValidationError
from B03_FileInput.B03_FileInput_Schema import FileUploadDescriptor
from config.config_system import UPLOAD_MAX_MB
class FileUploadDescriptorTest(unittest.TestCase):
def test_file_upload_descriptor(self) -> None:
descriptor = FileUploadDescriptor(
original_filename="cloud_merged.LAS",
size_bytes=1024,
)
self.assertEqual(descriptor.original_filename, "cloud_merged.LAS")
invalid_values = (
{"original_filename": "../cloud.las", "size_bytes": 1024},
{"original_filename": "nested/cloud.las", "size_bytes": 1024},
{"original_filename": "cloud.exe", "size_bytes": 1024},
{
"original_filename": "cloud.las",
"size_bytes": UPLOAD_MAX_MB * 1024 * 1024 + 1,
},
{"original_filename": "cloud.las", "size_bytes": 0},
)
for invalid_value in invalid_values:
with self.subTest(invalid_value=invalid_value):
with self.assertRaises(ValidationError):
FileUploadDescriptor(**invalid_value)
+64
View File
@@ -0,0 +1,64 @@
import tempfile
import unittest
from pathlib import Path
import laspy
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
from B04_wf1_Surface.B04_wf1_Surface_Engine_Ground import (
build_ground_masks,
summarize_masks,
)
class GroundMasksTest(unittest.TestCase):
def test_build_and_summarize(self) -> None:
coords = np.linspace(0.0, 20.0, 21)
gx, gy = np.meshgrid(coords, coords)
z = 5.0 + 0.03 * gx
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [float(z.min()), float(z.max())]])
data = {"xyz": xyz, "bounds": bounds}
masks = build_ground_masks(data, ["grid_min_z"])
self.assertIn("grid_min_z", masks)
self.assertEqual(masks["grid_min_z"].dtype, bool)
summary = summarize_masks(data, masks)
self.assertEqual(summary["grid_min_z"]["total_point_count"], len(xyz))
self.assertGreater(summary["grid_min_z"]["ground_ratio"], 0.9)
def test_unknown_filter(self) -> None:
data = {"xyz": np.zeros((1, 3)), "bounds": np.zeros((3, 2))}
with self.assertRaises(ValueError):
build_ground_masks(data, ["unknown"])
class RunSurfaceAnalysisTest(unittest.TestCase):
def test_full_pipeline(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "proj"
root.mkdir()
coords = np.linspace(0.0, 30.0, 31)
gx, gy = np.meshgrid(coords, coords)
z = 5.0 + 0.04 * gx + 0.02 * gy
src = Path(directory) / "cloud.las"
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
las.x, las.y, las.z = gx.ravel(), gy.ravel(), z.ravel()
las.write(src)
result = run_surface_analysis(
root, src, source_filters=["grid_min_z"], methods=["dtm", "tin"]
)
self.assertEqual(result["processed"]["point_count"], 961)
self.assertEqual(result["manifest"]["status"], "completed")
self.assertGreater(result["ground_summary"]["grid_min_z"]["ground_ratio"], 0.9)
model_types = {m["model_type"] for m in result["models"]}
self.assertEqual(model_types, {"dtm", "tin"})
for model in result["models"]:
self.assertTrue(model["model_file_path"].startswith("B04_wf1_Surface/"))
self.assertTrue(model["layers"])
# structured.npz가 processed 폴더에 생성됨
self.assertTrue((root / "B04_wf1_Surface" / "processed" / "structured.npz").is_file())
+48
View File
@@ -0,0 +1,48 @@
import unittest
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_CSF import filter_csf
class FilterCsfTest(unittest.TestCase):
def _ground_with_trees(self) -> dict[str, np.ndarray]:
"""평탄한 지면 격자 위에 높이 솟은 수목 포인트를 얹은 합성 데이터."""
coords = np.linspace(0.0, 10.0, 11)
gx, gy = np.meshgrid(coords, coords)
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
trees = np.array(
[
[2.0, 2.0, 15.0],
[5.0, 5.0, 18.0],
[8.0, 8.0, 16.0],
]
)
xyz = np.vstack([ground, trees])
return {"xyz": xyz}, ground.shape[0]
def test_classifies_ground_and_rejects_trees(self) -> None:
structured, ground_count = self._ground_with_trees()
mask = filter_csf(structured, cloth_resolution=1.5, class_threshold=0.5)
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
self.assertEqual(mask.dtype, bool)
# 지면 포인트는 대부분 지면으로 분류되어야 한다.
self.assertGreater(mask[:ground_count].mean(), 0.8)
# 솟은 수목 포인트는 지면에서 제외되어야 한다.
self.assertFalse(mask[ground_count:].any())
def test_empty_input(self) -> None:
mask = filter_csf({"xyz": np.zeros((0, 3))})
self.assertEqual(mask.shape[0], 0)
self.assertEqual(mask.dtype, bool)
def test_invalid_parameters(self) -> None:
structured, _ = self._ground_with_trees()
with self.assertRaises(ValueError):
filter_csf(structured, cloth_resolution=0.0)
with self.assertRaises(ValueError):
filter_csf(structured, rigidness=5)
with self.assertRaises(ValueError):
filter_csf(structured, iterations=0)
+25
View File
@@ -0,0 +1,25 @@
import unittest
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_Grid import filter_grid_min_z
class FilterGridMinZTest(unittest.TestCase):
def test_filter_grid_min_z(self) -> None:
structured = {
"xyz": np.array(
[
[0.0, 0.0, 10.0],
[0.5, 0.5, 11.0],
[0.8, 0.8, 12.0],
]
),
"bounds": np.array([[0.0, 0.8], [0.0, 0.8], [10.0, 12.0]]),
}
mask = filter_grid_min_z(structured, cell_size=2.0, height_threshold=1.5)
np.testing.assert_array_equal(mask, [True, True, False])
with self.assertRaises(ValueError):
filter_grid_min_z(structured, cell_size=0)
+42
View File
@@ -0,0 +1,42 @@
import unittest
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_PMF import filter_pmf
class FilterPmfTest(unittest.TestCase):
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
coords = np.linspace(0.0, 40.0, 21)
gx, gy = np.meshgrid(coords, coords)
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 5.0)])
trees = np.array(
[
[10.0, 10.0, 20.0],
[20.0, 20.0, 25.0],
[30.0, 30.0, 22.0],
]
)
xyz = np.vstack([ground, trees])
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [5.0, 25.0]])
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
def test_classifies_ground_and_rejects_trees(self) -> None:
structured, ground_count = self._ground_with_trees()
mask = filter_pmf(structured)
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
self.assertEqual(mask.dtype, bool)
self.assertGreater(mask[:ground_count].mean(), 0.9)
self.assertFalse(mask[ground_count:].any())
def test_empty_input(self) -> None:
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
mask = filter_pmf({"xyz": np.zeros((0, 3)), "bounds": bounds})
self.assertEqual(mask.shape[0], 0)
def test_invalid_parameters(self) -> None:
structured, _ = self._ground_with_trees()
with self.assertRaises(ValueError):
filter_pmf(structured, cell_size=0)
with self.assertRaises(ValueError):
filter_pmf(structured, initial_window_size=0)
+59
View File
@@ -0,0 +1,59 @@
import unittest
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Filter_RANSAC import (
filter_ransac,
fit_plane_ransac,
)
class FitPlaneRansacTest(unittest.TestCase):
def test_fits_horizontal_plane(self) -> None:
coords = np.linspace(0.0, 5.0, 6)
gx, gy = np.meshgrid(coords, coords)
plane = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 2.0)])
outliers = np.array([[1.0, 1.0, 10.0], [3.0, 3.0, 12.0]])
points = np.vstack([plane, outliers])
inliers = fit_plane_ransac(points, distance_threshold=0.3)
self.assertTrue(inliers[: plane.shape[0]].all())
self.assertFalse(inliers[plane.shape[0] :].any())
def test_too_few_points(self) -> None:
points = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])
inliers = fit_plane_ransac(points)
self.assertTrue(inliers.all())
class FilterRansacTest(unittest.TestCase):
def _ground_with_trees(self) -> tuple[dict[str, np.ndarray], int]:
coords = np.linspace(0.0, 20.0, 11)
gx, gy = np.meshgrid(coords, coords)
ground = np.column_stack([gx.ravel(), gy.ravel(), np.full(gx.size, 3.0)])
trees = np.array([[5.0, 5.0, 15.0], [15.0, 15.0, 18.0]])
xyz = np.vstack([ground, trees])
bounds = np.array([[0.0, 20.0], [0.0, 20.0], [3.0, 18.0]])
return {"xyz": xyz, "bounds": bounds}, ground.shape[0]
def test_classifies_ground(self) -> None:
structured, ground_count = self._ground_with_trees()
progress: list[int] = []
mask = filter_ransac(structured, progress_callback=progress.append)
self.assertEqual(mask.shape[0], structured["xyz"].shape[0])
self.assertGreater(mask[:ground_count].mean(), 0.9)
# progress는 호출되며 단조 증가하고 마지막에 100으로 완료된다.
self.assertTrue(progress)
self.assertEqual(progress, sorted(progress))
self.assertEqual(progress[-1], 100)
def test_empty_input(self) -> None:
bounds = np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]])
mask = filter_ransac({"xyz": np.zeros((0, 3)), "bounds": bounds})
self.assertEqual(mask.shape[0], 0)
def test_invalid_parameters(self) -> None:
structured, _ = self._ground_with_trees()
with self.assertRaises(ValueError):
filter_ransac(structured, local_grid_size=0)
with self.assertRaises(ValueError):
filter_ransac(structured, ransac_n=2)
+61
View File
@@ -0,0 +1,61 @@
import tempfile
import unittest
from pathlib import Path
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
from config.config_system import build_surface_model_config
class BuildAllTerrainModelsTest(unittest.TestCase):
def _synthetic_slope(self) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
coords = np.linspace(0.0, 40.0, 41)
gx, gy = np.meshgrid(coords, coords)
z = 5.0 + 0.05 * gx + 0.03 * gy
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
bounds = np.array([[0.0, 40.0], [0.0, 40.0], [float(z.min()), float(z.max())]])
mask = np.ones(len(xyz), dtype=bool)
return {"xyz": xyz, "bounds": bounds}, {"grid_min_z": mask}
def test_builds_all_representations(self) -> None:
structured, masks = self._synthetic_slope()
config = build_surface_model_config()
config["source_filters"] = ["grid_min_z"]
progress: list[tuple[int, str]] = []
with tempfile.TemporaryDirectory() as directory:
output_dir = Path(directory) / "proj" / "models"
manifest = build_all_terrain_models(
structured, masks, output_dir, config, progress=lambda p, m: progress.append((p, m))
)
self.assertEqual(manifest["status"], "completed")
self.assertEqual(manifest["failure_count"], 0)
methods = manifest["source_filters"]["grid_min_z"]["methods"]
self.assertEqual(set(methods), {"tin", "dtm", "nurbs", "implicit", "meshfree"})
for meta in methods.values():
self.assertEqual(meta["status"], "completed")
# DTM/TIN은 스무딩까지 완료
self.assertEqual(methods["dtm"]["smooth"]["status"], "completed")
self.assertEqual(methods["tin"]["smooth"]["status"], "completed")
# 등고선 캐시와 manifest 생성 확인
files = list(output_dir.iterdir())
self.assertTrue(any("contour_" in f.name for f in files))
self.assertTrue((output_dir / "manifest.json").is_file())
self.assertEqual(progress[-1][0], 100)
def test_cache_reuse_on_second_call(self) -> None:
structured, masks = self._synthetic_slope()
config = build_surface_model_config()
config["source_filters"] = ["grid_min_z"]
config["precompute"] = ["dtm"]
with tempfile.TemporaryDirectory() as directory:
output_dir = Path(directory) / "proj" / "models"
build_all_terrain_models(structured, masks, output_dir, config)
second = build_all_terrain_models(structured, masks, output_dir, config)
self.assertEqual(second["status"], "completed")
self.assertEqual(
second["source_filters"]["grid_min_z"]["methods"]["dtm"]["status"], "completed"
)
+143
View File
@@ -0,0 +1,143 @@
import json
import unittest
from typing import Any
from uuid import UUID
from B04_wf1_Surface.B04_wf1_Surface_Repository import (
create_processed_point_cloud,
create_surface_model,
create_terrain_layer,
list_surface_models,
)
class FakeCursor:
def __init__(self, *, lastrowid: int = 0, rows: list[tuple[Any, ...]] | None = None) -> None:
self.query = ""
self.arguments: tuple[Any, ...] = ()
self.lastrowid = lastrowid
self._rows = rows or []
async def __aenter__(self) -> "FakeCursor":
return self
async def __aexit__(self, *_exc: Any) -> None:
return None
async def execute(self, query: str, arguments: tuple[Any, ...] | None = None) -> None:
self.query = query
self.arguments = arguments or ()
async def fetchall(self) -> list[tuple[Any, ...]]:
return self._rows
class FakeConnection:
def __init__(self, cursor: FakeCursor) -> None:
self._cursor = cursor
def cursor(self) -> FakeCursor:
return self._cursor
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
class ProcessedPointCloudTest(unittest.IsolatedAsyncioTestCase):
async def test_create(self) -> None:
cursor = FakeCursor(lastrowid=11)
new_id = await create_processed_point_cloud( # type: ignore[arg-type]
FakeConnection(cursor),
input_file_id=3,
project_id=PROJECT_ID,
process_type="filtered",
processed_file_path="B04_wf1_Surface/processed/cloud.las",
converted_format="ply",
converted_file_path="B04_wf1_Surface/processed/cloud.ply",
point_count=1000,
bounds={"x_min": 0.0, "x_max": 10.0, "y_min": 0.0, "y_max": 10.0},
statistics={"min_z": 1.0, "max_z": 5.0, "mean_z": 3.0, "density_per_sqm": 10.0},
classification_summary={"ground": 800},
processing_params={"filter": "csf"},
)
self.assertEqual(new_id, 11)
self.assertIn("INSERT INTO processed_point_cloud", cursor.query)
self.assertEqual(cursor.arguments[1], str(PROJECT_ID))
self.assertEqual(cursor.arguments[6], 1000)
self.assertEqual(json.loads(cursor.arguments[15]), {"ground": 800})
async def test_rejects_path_outside_stage(self) -> None:
with self.assertRaises(ValueError):
await create_processed_point_cloud( # type: ignore[arg-type]
FakeConnection(FakeCursor(lastrowid=1)),
input_file_id=3,
project_id=PROJECT_ID,
process_type="filtered",
processed_file_path="B05_wf2_Route/x.las",
converted_format=None,
converted_file_path=None,
point_count=None,
bounds=None,
statistics=None,
classification_summary=None,
processing_params=None,
)
class SurfaceModelTest(unittest.IsolatedAsyncioTestCase):
async def test_create(self) -> None:
cursor = FakeCursor(lastrowid=22)
new_id = await create_surface_model( # type: ignore[arg-type]
FakeConnection(cursor),
project_id=PROJECT_ID,
model_type="dtm_grid",
source_file_id=3,
processed_cloud_id=11,
crs_epsg=5178,
resolution_m=1.0,
model_file_path="B04_wf1_Surface/models/dtm.npz",
generation_params={"algorithm": "dtm"},
)
self.assertEqual(new_id, 22)
self.assertIn("INSERT INTO surface_models", cursor.query)
self.assertEqual(cursor.arguments[7], "B04_wf1_Surface/models/dtm.npz")
class TerrainLayerTest(unittest.IsolatedAsyncioTestCase):
async def test_create(self) -> None:
cursor = FakeCursor(lastrowid=33)
new_id = await create_terrain_layer( # type: ignore[arg-type]
FakeConnection(cursor),
surface_model_id=22,
layer_name="지표",
geometry_type="GRID",
layer_file_path="B04_wf1_Surface/models/layer_ground.geojson",
file_format="geojson",
file_size_mb=1.5,
statistics={"point_count": 500},
)
self.assertEqual(new_id, 33)
self.assertIn("INSERT INTO terrain_layers", cursor.query)
class ListSurfaceModelsTest(unittest.IsolatedAsyncioTestCase):
async def test_list(self) -> None:
import datetime
rows = [
(
22,
"dtm_grid",
"COMPLETE",
1.0,
"B04_wf1_Surface/models/dtm.npz",
datetime.datetime(2026, 7, 5, 12, 0, 0),
),
]
result = await list_surface_models( # type: ignore[arg-type]
FakeConnection(FakeCursor(rows=rows)), PROJECT_ID
)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["id"], 22)
self.assertEqual(result[0]["model_type"], "dtm_grid")
self.assertTrue(result[0]["created_at"].startswith("2026-07-05"))
+40
View File
@@ -0,0 +1,40 @@
import tempfile
import unittest
from pathlib import Path
import laspy
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Structurize import structurize_las
class StructurizeLasTest(unittest.TestCase):
def test_structurize_las(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
source = Path(temporary_directory) / "source.las"
output_dir = Path(temporary_directory) / "processed"
las = laspy.LasData(laspy.LasHeader(point_format=3, version="1.2"))
las.x = np.array([100.0, 102.0])
las.y = np.array([200.0, 204.0])
las.z = np.array([10.0, 14.0])
las.intensity = np.array([7, 9], dtype=np.uint16)
las.red = np.array([65535, 256], dtype=np.uint16)
las.green = np.array([32768, 512], dtype=np.uint16)
las.blue = np.array([0, 768], dtype=np.uint16)
las.classification = np.array([2, 5], dtype=np.uint8)
las.write(source)
progress: list[int] = []
target = structurize_las(source, output_dir, progress.append)
self.assertEqual(target, output_dir / "structured.npz")
self.assertEqual(progress[-1], 100)
with np.load(target) as structured:
np.testing.assert_allclose(
structured["xyz"],
np.array([[100.0, 200.0, 10.0], [102.0, 204.0, 14.0]]),
)
np.testing.assert_array_equal(structured["intensity"], [7, 9])
np.testing.assert_array_equal(structured["rgb"], [[255, 128, 0], [1, 2, 3]])
np.testing.assert_array_equal(structured["classification"], [2, 5])
np.testing.assert_array_equal(structured["total_points"], [2])
+64
View File
@@ -0,0 +1,64 @@
import json
import tempfile
import unittest
from pathlib import Path
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
from B05_wf2_Route.B05_wf2_Route_Engine import run_route_design
from B05_wf2_Route.B05_wf2_Route_Router import router
from config.config_system import build_surface_model_config
class RouteEngineTest(unittest.TestCase):
def _make_dtm(self, root: Path) -> None:
models = root / "B04_wf1_Surface" / "models"
coords = np.linspace(0.0, 60.0, 61)
gx, gy = np.meshgrid(coords, coords)
z = 5.0 + 0.03 * gx + 0.02 * gy
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
cfg = build_surface_model_config()
cfg["source_filters"] = ["grid_min_z"]
cfg["precompute"] = ["dtm"]
build_all_terrain_models(
{"xyz": xyz, "bounds": bounds},
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
models,
cfg,
)
def test_run_route_design(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "proj"
self._make_dtm(root)
points = {
"bp": {"x": 5.0, "y": 5.0},
"ep": {"x": 55.0, "y": 55.0},
"cp": [],
"ap": [],
"fp": [],
}
design = run_route_design(root, "grid_min_z", "dtm", False, points, {})
# GeoJSON 저장 확인
self.assertEqual(design["route_data_path"], "B05_wf2_Route/route/route_main.geojson")
geojson_file = root / design["route_data_path"]
self.assertTrue(geojson_file.is_file())
geo = json.loads(geojson_file.read_text(encoding="utf-8"))
self.assertEqual(geo["geometry"]["type"], "LineString")
self.assertGreater(len(geo["geometry"]["coordinates"]), 2)
# 렌더링 샘플 & 통계
self.assertTrue(design["render_points"])
self.assertEqual(design["render_points"][0]["sequence_num"], 0)
self.assertIsNotNone(design["statistics"]["max_slope"])
self.assertTrue(design["solver_result"]["required_points_ok"])
class RouterRegistrationTest(unittest.TestCase):
def test_routes_registered(self) -> None:
paths = {r.path for r in router.routes}
self.assertIn("/api/projects/{project_id}/route/solve", paths)
self.assertIn("/api/projects/{project_id}/route/confirm", paths)
+148
View File
@@ -0,0 +1,148 @@
import json
import unittest
from typing import Any
from uuid import UUID
from B05_wf2_Route.B05_wf2_Route_Repository import (
confirm_route,
create_route,
create_route_statistics,
get_latest_route,
insert_route_points,
)
class FakeCursor:
def __init__(self, *, lastrowid: int = 0, row: tuple[Any, ...] | None = None) -> None:
self.query = ""
self.arguments: Any = ()
self.many_rows: list[tuple[Any, ...]] = []
self.lastrowid = lastrowid
self._row = row
async def __aenter__(self) -> "FakeCursor":
return self
async def __aexit__(self, *_exc: Any) -> None:
return None
async def execute(self, query: str, arguments: Any = None) -> None:
self.query = query
self.arguments = arguments or ()
async def executemany(self, query: str, rows: list[tuple[Any, ...]]) -> None:
self.query = query
self.many_rows = rows
async def fetchone(self) -> tuple[Any, ...] | None:
return self._row
class FakeConnection:
def __init__(self, cursor: FakeCursor) -> None:
self._cursor = cursor
def cursor(self) -> FakeCursor:
return self._cursor
PROJECT_ID = UUID("550e8400-e29b-41d4-a716-446655440000")
class CreateRouteTest(unittest.IsolatedAsyncioTestCase):
async def test_create(self) -> None:
cursor = FakeCursor(lastrowid=7)
route_id = await create_route( # type: ignore[arg-type]
FakeConnection(cursor),
project_id=PROJECT_ID,
surface_model_id=22,
total_length_m=100.0,
start_chainage_m=0.0,
end_chainage_m=100.0,
grade_percent=[2.5, 1.8],
constraints={"max_grade": 0.14},
algorithm_params={"weights": {"dist": 1.0}},
route_data_path="B05_wf2_Route/route/route_main.geojson",
)
self.assertEqual(route_id, 7)
self.assertIn("INSERT INTO routes", cursor.query)
self.assertEqual(cursor.arguments[0], str(PROJECT_ID))
self.assertEqual(json.loads(cursor.arguments[6]), [2.5, 1.8])
async def test_rejects_path_outside_stage(self) -> None:
with self.assertRaises(ValueError):
await create_route( # type: ignore[arg-type]
FakeConnection(FakeCursor(lastrowid=1)),
project_id=PROJECT_ID,
surface_model_id=None,
total_length_m=None,
start_chainage_m=None,
end_chainage_m=None,
grade_percent=None,
constraints=None,
algorithm_params=None,
route_data_path="B04_wf1_Surface/x.geojson",
)
class RoutePointsTest(unittest.IsolatedAsyncioTestCase):
async def test_insert_many(self) -> None:
cursor = FakeCursor()
count = await insert_route_points( # type: ignore[arg-type]
FakeConnection(cursor),
7,
[
{"chainage_m": 0.0, "elevation_m": 5.0, "slope_percent": 0.0, "sequence_num": 0},
{"chainage_m": 10.0, "elevation_m": 5.3, "slope_percent": 3.0, "sequence_num": 1},
],
)
self.assertEqual(count, 2)
self.assertEqual(len(cursor.many_rows), 2)
self.assertEqual(cursor.many_rows[0][0], 7)
async def test_insert_empty(self) -> None:
count = await insert_route_points(FakeConnection(FakeCursor()), 7, []) # type: ignore[arg-type]
self.assertEqual(count, 0)
class RouteStatisticsTest(unittest.IsolatedAsyncioTestCase):
async def test_create(self) -> None:
cursor = FakeCursor(lastrowid=3)
stat_id = await create_route_statistics( # type: ignore[arg-type]
FakeConnection(cursor),
route_id=7,
min_slope=0.0,
max_slope=7.0,
mean_slope=3.5,
cost_score=12.3,
)
self.assertEqual(stat_id, 3)
self.assertIn("INSERT INTO route_statistics", cursor.query)
class LatestRouteTest(unittest.IsolatedAsyncioTestCase):
async def test_found(self) -> None:
import datetime
row = (
7,
"CONFIRMED",
100.0,
"B05_wf2_Route/route/route_main.geojson",
datetime.datetime(2026, 7, 5, 12, 0, 0),
)
result = await get_latest_route(FakeConnection(FakeCursor(row=row)), PROJECT_ID) # type: ignore[arg-type]
self.assertEqual(result["id"], 7)
self.assertEqual(result["status"], "CONFIRMED")
async def test_none(self) -> None:
result = await get_latest_route(FakeConnection(FakeCursor(row=None)), PROJECT_ID) # type: ignore[arg-type]
self.assertIsNone(result)
class ConfirmRouteTest(unittest.IsolatedAsyncioTestCase):
async def test_confirm(self) -> None:
cursor = FakeCursor()
await confirm_route(FakeConnection(cursor), 7) # type: ignore[arg-type]
self.assertIn("UPDATE routes SET status = 'CONFIRMED'", cursor.query)
self.assertEqual(cursor.arguments[0], 7)
+44
View File
@@ -0,0 +1,44 @@
import math
import unittest
from B05_wf2_Route.B05_wf2_Route_Engine_RidgeValley import (
_fillet_alignment,
_turn_angle,
resolve_grade_bounds,
)
class ResolveGradeBoundsTest(unittest.TestCase):
def test_defaults(self) -> None:
gb = resolve_grade_bounds({})
self.assertAlmostEqual(gb["min_uphill_grade"], 0.08)
self.assertAlmostEqual(gb["max_uphill_grade"], 0.14)
# 엣지용은 방향 중 더 엄격한 값
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.14)
self.assertAlmostEqual(gb["min_grade_for_edges"], 0.08)
def test_override(self) -> None:
gb = resolve_grade_bounds({"max_uphill_grade": 0.10, "max_downhill_grade": 0.12})
self.assertAlmostEqual(gb["max_grade_for_edges"], 0.10)
class TurnAngleTest(unittest.TestCase):
def test_straight_is_zero(self) -> None:
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [2, 0]), 0.0, places=6)
def test_right_angle(self) -> None:
self.assertAlmostEqual(_turn_angle([0, 0], [1, 0], [1, 1]), math.pi / 2, places=6)
class FilletAlignmentTest(unittest.TestCase):
def test_straight_nodes_preserved(self) -> None:
nodes = [[0, 0, 0], [10, 0, 1], [20, 0, 2]]
poly, radii = _fillet_alignment(nodes, 12.0, 2.0)
self.assertGreaterEqual(len(poly), 2)
# 직선이므로 시작·끝 좌표 보존
self.assertAlmostEqual(poly[0][0], 0.0)
self.assertAlmostEqual(poly[-1][0], 20.0)
def test_single_node(self) -> None:
poly, radii = _fillet_alignment([[0, 0, 0]], 12.0, 2.0)
self.assertEqual(len(poly), 1)
+51
View File
@@ -0,0 +1,51 @@
import unittest
import numpy as np
from B05_wf2_Route.B05_wf2_Route_Engine_Skeleton import (
d8_flow_accumulation_numpy,
extract_skeleton_from_grid,
)
class D8FlowAccumulationTest(unittest.TestCase):
def test_valley_concentrates_flow(self) -> None:
# V자 계곡: 중앙 열(x=20)이 가장 낮다 → 흐름이 중앙에 모인다.
coords = np.linspace(0, 40, 41)
gx, gy = np.meshgrid(coords, coords)
z = 10.0 + np.abs(gx - 20.0) * 0.3
valid = np.ones_like(z, dtype=bool)
acc = d8_flow_accumulation_numpy(z, valid)
# 누적 최대 셀은 중앙 열(index 20) 근처여야 한다.
_, max_col = np.unravel_index(acc.argmax(), acc.shape)
self.assertTrue(18 <= max_col <= 22)
self.assertGreater(acc.max(), 10.0)
def test_flat_grid_uniform(self) -> None:
z = np.full((10, 10), 5.0)
valid = np.ones_like(z, dtype=bool)
acc = d8_flow_accumulation_numpy(z, valid)
# 평지는 하강 이웃이 없어 각 셀 누적이 1.
self.assertTrue(np.allclose(acc, 1.0))
class ExtractSkeletonTest(unittest.TestCase):
def test_valley_detected_with_low_threshold(self) -> None:
coords = np.linspace(0, 40, 41)
gx, gy = np.meshgrid(coords, coords)
z = 10.0 + np.abs(gx - 20.0) * 0.3
valid = np.ones_like(z, dtype=bool)
thresholds = {
"valley_acc": 5.0,
"main_valley_acc": 30.0,
"ridge_acc": 5.0,
"main_ridge_acc": 30.0,
}
result = extract_skeleton_from_grid(
coords, coords, z, valid, 1.0, thresholds=thresholds, use_whitebox=False
)
self.assertEqual(set(result), {"main_ridge", "minor_ridge", "main_valley", "minor_valley"})
# 계곡 클래스에 최소 하나의 polyline이 검출되어야 한다.
valley_total = len(result["main_valley"]) + len(result["minor_valley"])
self.assertGreater(valley_total, 0)
+103
View File
@@ -0,0 +1,103 @@
import tempfile
import unittest
from pathlib import Path
import numpy as np
from B04_wf1_Surface.B04_wf1_Surface_Engine_Pipeline import build_all_terrain_models
from B05_wf2_Route.B05_wf2_Route_Engine_Geometry import (
circumradius_2d,
point_to_polyline_dist_2d,
single_segment_dijkstra,
)
from B05_wf2_Route.B05_wf2_Route_Engine_Solver import solve_optimal_route
from config.config_system import build_surface_model_config
class GeometryTest(unittest.TestCase):
def test_circumradius_straight_line_is_inf(self) -> None:
r = circumradius_2d([0, 0], [1, 0], [2, 0])
self.assertEqual(r, float("inf"))
def test_circumradius_right_angle(self) -> None:
r = circumradius_2d([0, 0], [1, 0], [1, 1])
self.assertTrue(np.isfinite(r) and r > 0)
def test_point_to_polyline_distance(self) -> None:
poly = [[0, 0], [10, 0]]
self.assertAlmostEqual(point_to_polyline_dist_2d(5, 3, poly), 3.0, places=6)
def test_dijkstra_on_flat_grid(self) -> None:
coords = np.linspace(0.0, 10.0, 11)
z = np.full((11, 11), 5.0)
valid = np.ones((11, 11), dtype=bool)
dz_dx = np.zeros((11, 11))
dz_dy = np.zeros((11, 11))
weights = {"dist": 1.0, "grade": 2.0, "side": 1.5, "curve": 0.5, "avoid": 10.0}
path = single_segment_dijkstra(
0,
0,
10,
10,
coords,
coords,
z,
valid,
dz_dx,
dz_dy,
[],
weights,
0.14,
1.0,
12.0,
)
self.assertTrue(path)
self.assertEqual(path[0], (0, 0))
self.assertEqual(path[-1], (10, 10))
class SolveOptimalRouteTest(unittest.TestCase):
def _make_dtm(self, root: Path) -> None:
models = root / "B04_wf1_Surface" / "models"
coords = np.linspace(0.0, 60.0, 61)
gx, gy = np.meshgrid(coords, coords)
z = 5.0 + 0.03 * gx + 0.02 * gy
xyz = np.column_stack([gx.ravel(), gy.ravel(), z.ravel()]).astype(np.float64)
bounds = np.array([[0.0, 60.0], [0.0, 60.0], [float(z.min()), float(z.max())]])
cfg = build_surface_model_config()
cfg["source_filters"] = ["grid_min_z"]
cfg["precompute"] = ["dtm"]
build_all_terrain_models(
{"xyz": xyz, "bounds": bounds},
{"grid_min_z": np.ones(len(xyz), dtype=bool)},
models,
cfg,
)
def test_diagonal_route(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "proj"
self._make_dtm(root)
points = {
"bp": {"x": 5.0, "y": 5.0},
"ep": {"x": 55.0, "y": 55.0},
"cp": [],
"ap": [],
"fp": [],
}
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
self.assertGreater(len(result["polyline"]), 2)
self.assertTrue(result["required_points_ok"])
self.assertEqual(len(result["segments"]), 1)
# 대각선 50m×50m → 길이 ≈ 70.7m
self.assertAlmostEqual(result["metrics"]["length_m"], 70.71, delta=5.0)
def test_missing_bp_returns_empty(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "proj"
self._make_dtm(root)
points = {"bp": None, "ep": {"x": 55.0, "y": 55.0}, "cp": [], "ap": [], "fp": []}
result = solve_optimal_route(root, "grid_min_z", False, points, {}, method="dtm")
self.assertEqual(result["polyline"], [])
self.assertFalse(result["required_points_ok"])
+71
View File
@@ -0,0 +1,71 @@
import unittest
import numpy as np
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Sampler import (
CallableSurfaceSampler,
DtmGridSampler,
)
from B06_wf3_ProfileCross.B06_wf3_ProfileCross_Engine_Section import (
SectionGenerationOptions,
format_station,
generate_sections,
)
class FormatStationTest(unittest.TestCase):
def test_format(self) -> None:
self.assertEqual(format_station(0.0), "STA.0+000.000")
self.assertEqual(format_station(1234.5), "STA.1+234.500")
self.assertEqual(format_station(20.0), "STA.0+020.000")
class DtmGridSamplerTest(unittest.TestCase):
def test_sample_inside_and_outside(self) -> None:
x = np.array([0.0, 1.0, 2.0])
y = np.array([0.0, 1.0, 2.0])
z = np.array([[0.0, 1.0, 2.0], [1.0, 2.0, 3.0], [2.0, 3.0, 4.0]])
valid = np.ones((3, 3), dtype=bool)
sampler = DtmGridSampler(x, y, z, valid)
zq, vq = sampler.sample_xy(np.array([[0.5, 0.5], [10.0, 10.0]]))
self.assertTrue(vq[0])
self.assertFalse(vq[1]) # 격자 밖
self.assertAlmostEqual(zq[0], 1.0) # 이중선형 보간
def test_invalid_shape(self) -> None:
with self.assertRaises(ValueError):
DtmGridSampler(np.array([0.0]), np.array([0.0, 1.0]), np.zeros((2, 1)), np.ones((2, 1)))
class GenerateSectionsTest(unittest.TestCase):
def test_straight_route(self) -> None:
sampler = CallableSurfaceSampler(lambda xy: 5.0 + 0.05 * xy[:, 0] + 0.02 * xy[:, 1])
polyline = [[float(x), 0.0, 5.0 + 0.05 * x] for x in range(0, 101, 10)]
result = generate_sections(
polyline,
sampler,
SectionGenerationOptions(station_interval_m=20.0, cross_half_width_m=10.0),
)
self.assertEqual(result["status"], "completed")
self.assertAlmostEqual(result["longitudinal"]["length_m"], 100.0)
self.assertEqual(result["summary"]["station_count"], 6)
self.assertEqual(len(result["cross_sections"]), 6)
# 횡단 샘플: ±10m를 0.5m 간격 → 41개
self.assertEqual(len(result["cross_sections"][0]["samples"]), 41)
# BP/EP 종류 표기
self.assertEqual(result["longitudinal"]["stations"][0]["kind"], "bp")
self.assertEqual(result["longitudinal"]["stations"][-1]["kind"], "ep")
def test_invalid_options(self) -> None:
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
polyline = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]
with self.assertRaises(ValueError):
generate_sections(polyline, sampler, SectionGenerationOptions(station_interval_m=0.0))
def test_too_short_polyline(self) -> None:
sampler = CallableSurfaceSampler(lambda xy: np.zeros(len(xy)))
with self.assertRaises(ValueError):
generate_sections([[0.0, 0.0, 0.0]], sampler)
+18
View File
@@ -0,0 +1,18 @@
import json
import tempfile
import unittest
from pathlib import Path
from common_util.common_util_json import atomic_write_json
class AtomicWriteJsonTest(unittest.TestCase):
def test_atomic_write_json(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
target = Path(temporary_directory) / "nested" / "result.json"
value = {"status": "완료", "items": [1, 2, 3]}
atomic_write_json(target, value)
self.assertEqual(json.loads(target.read_text(encoding="utf-8")), value)
self.assertEqual(list(target.parent.glob("*.tmp")), [])
+63
View File
@@ -0,0 +1,63 @@
import json
import tempfile
import unittest
from pathlib import Path
from common_util.common_util_workflow import (
load_project_workflow,
patch_project_workflow_stale,
)
class LoadProjectWorkflowTest(unittest.TestCase):
def test_load_project_workflow(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
project_root = Path(temporary_directory)
self.assertEqual(
load_project_workflow(project_root),
{
"current_stage": "scan",
"completed": [],
"stale_from": None,
"stage1_confirmed": None,
},
)
saved = {"current_stage": "route", "completed": ["scan"]}
(project_root / "workflow.json").write_text(
json.dumps(saved, ensure_ascii=False),
encoding="utf-8",
)
self.assertEqual(load_project_workflow(project_root), saved)
(project_root / "workflow.json").write_text("[]", encoding="utf-8")
with self.assertRaises(ValueError):
load_project_workflow(project_root)
def test_patch_project_workflow_stale(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
project_root = Path(temporary_directory)
workflow_path = project_root / "workflow.json"
patch_project_workflow_stale(project_root, "route")
self.assertFalse(workflow_path.exists())
saved = {
"current_stage": "surface",
"completed": ["scan"],
"stale_from": None,
"stage1_confirmed": {"method": "tin"},
}
workflow_path.write_text(
json.dumps(saved, ensure_ascii=False),
encoding="utf-8",
)
patch_project_workflow_stale(project_root, "route")
updated = json.loads(workflow_path.read_text(encoding="utf-8"))
self.assertEqual(updated["stale_from"], "route")
self.assertEqual(updated["current_stage"], saved["current_stage"])
self.assertEqual(updated["completed"], saved["completed"])
self.assertEqual(updated["stage1_confirmed"], saved["stage1_confirmed"])
+59
View File
@@ -0,0 +1,59 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from common_util.common_util_storage import (
get_project_stage_path,
resolve_stored_project_path,
)
from config.config_system import get_project_storage_path
class ProjectStoragePathTest(unittest.TestCase):
def test_get_project_storage_path(self) -> None:
with tempfile.TemporaryDirectory() as storage_root:
with patch("config.config_system.STORAGE_BASE_DIR", storage_root):
result = get_project_storage_path("회사", "사용자", "project-id")
self.assertEqual(
result,
os.path.join(storage_root, "회사", "사용자", "project-id"),
)
self.assertTrue(os.path.isdir(result))
self.assertNotIn(f"{os.sep}projects{os.sep}", result)
for invalid_segment in ("", ".", "..", "../escape", "nested/path", "nested\\path"):
with self.subTest(invalid_segment=invalid_segment):
with self.assertRaises(ValueError):
get_project_storage_path(invalid_segment, "사용자", "project-id")
def test_get_project_stage_path(self) -> None:
with tempfile.TemporaryDirectory() as project_root:
result = get_project_stage_path(project_root, "B03_FileInput")
self.assertEqual(result, os.path.join(project_root, "B03_FileInput"))
self.assertTrue(os.path.isdir(result))
for invalid_stage in ("", "B02_ProjRegister", "../escape"):
with self.subTest(invalid_stage=invalid_stage):
with self.assertRaises(ValueError):
get_project_stage_path(project_root, invalid_stage)
def test_resolve_stored_project_path(self) -> None:
with tempfile.TemporaryDirectory() as storage_root:
with patch("common_util.common_util_storage.STORAGE_BASE_DIR", storage_root):
result = resolve_stored_project_path("storage/company/user/project-id")
expected = os.path.join(storage_root, "company", "user", "project-id")
self.assertEqual(result, expected)
self.assertTrue(os.path.isdir(result))
for invalid_path in ("company/user/project", "../outside", "storage"):
with self.subTest(invalid_path=invalid_path):
with self.assertRaises(ValueError):
resolve_stored_project_path(invalid_path)
if __name__ == "__main__":
unittest.main()