初次提交
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# tests package
|
||||
80
tests/conftest.py
Normal file
80
tests/conftest.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
tests/conftest.py
|
||||
~~~~~~~~~~~~~~~~~
|
||||
共享测试夹具(Fixture)—— 提供测试数据,供所有测试文件复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import geopandas as gpd
|
||||
import pytest
|
||||
from shapely.geometry import Point, Polygon
|
||||
|
||||
|
||||
# ── 示例 GeoDataFrame ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def sample_points_gdf() -> gpd.GeoDataFrame:
|
||||
"""3 个点的 GeoDataFrame(WGS84)。"""
|
||||
return gpd.GeoDataFrame(
|
||||
{
|
||||
"id": [1, 2, 3],
|
||||
"name": ["点A", "点B", "点C"],
|
||||
"value": [10.5, 20.0, 15.3],
|
||||
},
|
||||
geometry=[Point(116.4, 39.9), Point(121.5, 31.2), Point(113.3, 23.1)],
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_polygon_gdf() -> gpd.GeoDataFrame:
|
||||
"""1 个矩形多边形的 GeoDataFrame(WGS84)。"""
|
||||
poly = Polygon([(115.0, 38.0), (122.0, 38.0), (122.0, 41.0), (115.0, 41.0)])
|
||||
return gpd.GeoDataFrame(
|
||||
{"region": ["华北区"], "area_km2": [450000.0]},
|
||||
geometry=[poly],
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_multi_polygon_gdf() -> gpd.GeoDataFrame:
|
||||
"""包含两个多边形的 GeoDataFrame,用于融合/叠置测试(WGS84)。"""
|
||||
poly1 = Polygon([(100, 20), (110, 20), (110, 30), (100, 30)])
|
||||
poly2 = Polygon([(105, 20), (115, 20), (115, 30), (105, 30)])
|
||||
return gpd.GeoDataFrame(
|
||||
{"zone": ["A", "B"], "value": [100, 200]},
|
||||
geometry=[poly1, poly2],
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
|
||||
|
||||
# ── 临时文件路径 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_geojson_path(tmp_path: Path, sample_points_gdf: gpd.GeoDataFrame) -> Path:
|
||||
"""将 sample_points_gdf 写出为临时 GeoJSON 并返回路径。"""
|
||||
path = tmp_path / "sample.geojson"
|
||||
sample_points_gdf.to_file(str(path), driver="GeoJSON")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_gpkg_path(tmp_path: Path, sample_points_gdf: gpd.GeoDataFrame) -> Path:
|
||||
"""将 sample_points_gdf 写出为临时 GPKG 并返回路径。"""
|
||||
path = tmp_path / "sample.gpkg"
|
||||
sample_points_gdf.to_file(str(path), driver="GPKG", layer="points")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_output_dir(tmp_path: Path) -> Path:
|
||||
"""空的临时输出目录。"""
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
return out
|
||||
20
tests/test1.py
Normal file
20
tests/test1.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import sys
|
||||
import os
|
||||
os.environ["OGR_ORGANIZE_POLYGONS"] = "SKIP"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import geo_tools
|
||||
|
||||
gdb_path = r"E:\@三普\@临时文件夹\临时数据库.gdb"
|
||||
|
||||
# 列出图层
|
||||
# layers = geo_tools.list_gdb_layers(gdb_path)
|
||||
# print(layers)
|
||||
|
||||
# 读取图层
|
||||
gdf = geo_tools.read_gdb(gdb_path, layer="马关综合后图斑")
|
||||
print(gdf.crs)
|
||||
80
tests/test_analysis.py
Normal file
80
tests/test_analysis.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""tests/test_analysis.py —— 空间分析单元测试。"""
|
||||
|
||||
import pytest
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import Point, Polygon
|
||||
|
||||
from geo_tools.analysis.spatial_ops import overlay, select_by_location
|
||||
from geo_tools.analysis.stats import area_weighted_mean, count_by_polygon, summarize_attributes
|
||||
|
||||
|
||||
class TestOverlay:
|
||||
def test_intersection(self, sample_multi_polygon_gdf):
|
||||
poly_a = sample_multi_polygon_gdf.iloc[[0]].copy()
|
||||
poly_b = sample_multi_polygon_gdf.iloc[[1]].copy()
|
||||
result = overlay(poly_a, poly_b, how="intersection")
|
||||
assert len(result) >= 1
|
||||
assert result.geometry.is_valid.all()
|
||||
|
||||
def test_union(self, sample_multi_polygon_gdf):
|
||||
poly_a = sample_multi_polygon_gdf.iloc[[0]].copy()
|
||||
poly_b = sample_multi_polygon_gdf.iloc[[1]].copy()
|
||||
result = overlay(poly_a, poly_b, how="union", keep_geom_type=False)
|
||||
assert result.geometry.is_valid.all()
|
||||
|
||||
|
||||
class TestSelectByLocation:
|
||||
def test_select_points_in_polygon(self, sample_points_gdf, sample_polygon_gdf):
|
||||
# polygon 覆盖华北区,应选中 北京 点
|
||||
result = select_by_location(sample_points_gdf, sample_polygon_gdf, predicate="intersects")
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_select_within(self, sample_points_gdf, sample_polygon_gdf):
|
||||
result = select_by_location(sample_points_gdf, sample_polygon_gdf, predicate="within")
|
||||
assert len(result) >= 0 # 可能有点在边界上
|
||||
|
||||
|
||||
class TestAreaWeightedMean:
|
||||
def test_global_weighted_mean(self, sample_multi_polygon_gdf):
|
||||
result = area_weighted_mean(sample_multi_polygon_gdf, value_col="value")
|
||||
assert "area_weighted_mean" in result.index
|
||||
assert result["area_weighted_mean"] > 0
|
||||
|
||||
def test_grouped_weighted_mean(self, sample_multi_polygon_gdf):
|
||||
gdf = sample_multi_polygon_gdf.copy()
|
||||
gdf["group"] = ["A", "B"]
|
||||
result = area_weighted_mean(gdf, value_col="value", group_col="group")
|
||||
assert "area_weighted_mean" in result.columns
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestSummarizeAttributes:
|
||||
def test_basic_summary(self, sample_points_gdf):
|
||||
result = summarize_attributes(sample_points_gdf, columns=["value"])
|
||||
assert "column" in result.columns
|
||||
assert "mean" in result.columns
|
||||
|
||||
def test_grouped_summary(self, sample_points_gdf):
|
||||
gdf = sample_points_gdf.copy()
|
||||
gdf["group"] = ["北方", "东部", "南方"]
|
||||
result = summarize_attributes(gdf, columns=["value"], group_col="group")
|
||||
# 每组一行
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
class TestCountByPolygon:
|
||||
def test_count_points_in_polygons(self, sample_points_gdf, sample_polygon_gdf):
|
||||
result = count_by_polygon(sample_points_gdf, sample_polygon_gdf)
|
||||
assert "point_count" in result.columns
|
||||
assert result["point_count"].dtype.kind == "i" # 整数
|
||||
|
||||
def test_polygon_with_no_points(self):
|
||||
# 南海中的 polygon,不含任何点
|
||||
poly = Polygon([(115, 10), (120, 10), (120, 15), (115, 15)])
|
||||
polygons = gpd.GeoDataFrame({"id": [1]}, geometry=[poly], crs="EPSG:4326")
|
||||
points = gpd.GeoDataFrame(
|
||||
geometry=[Point(116.4, 39.9)], # 北京,不在 polygon 内
|
||||
crs="EPSG:4326",
|
||||
)
|
||||
result = count_by_polygon(points, polygons)
|
||||
assert result["point_count"].iloc[0] == 0
|
||||
110
tests/test_geometry.py
Normal file
110
tests/test_geometry.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""tests/test_geometry.py —— 几何运算单元测试。"""
|
||||
|
||||
import pytest
|
||||
from shapely.geometry import LineString, Point, Polygon
|
||||
|
||||
import geo_tools
|
||||
from geo_tools.core.geometry import (
|
||||
buffer_geometry,
|
||||
bounding_box,
|
||||
centroid,
|
||||
contains,
|
||||
convex_hull,
|
||||
difference,
|
||||
distance_between,
|
||||
fix_geometry,
|
||||
intersect,
|
||||
intersects,
|
||||
is_valid_geometry,
|
||||
unary_union,
|
||||
union,
|
||||
within,
|
||||
)
|
||||
|
||||
|
||||
class TestIsValidGeometry:
|
||||
def test_valid_polygon(self):
|
||||
poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
|
||||
assert is_valid_geometry(poly) is True
|
||||
|
||||
def test_none_returns_false(self):
|
||||
assert is_valid_geometry(None) is False
|
||||
|
||||
def test_invalid_self_intersecting(self):
|
||||
# 蝴蝶形(自相交)
|
||||
bowtie = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)])
|
||||
assert is_valid_geometry(bowtie) is False
|
||||
|
||||
|
||||
class TestFixGeometry:
|
||||
def test_fix_bowtie(self):
|
||||
bowtie = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)])
|
||||
assert not bowtie.is_valid
|
||||
fixed = fix_geometry(bowtie)
|
||||
assert fixed is not None
|
||||
assert fixed.is_valid
|
||||
|
||||
def test_valid_geometry_unchanged(self):
|
||||
poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
|
||||
fixed = fix_geometry(poly)
|
||||
assert fixed.is_valid
|
||||
assert fixed.area == pytest.approx(poly.area)
|
||||
|
||||
def test_none_returns_none(self):
|
||||
assert fix_geometry(None) is None
|
||||
|
||||
|
||||
class TestBufferGeometry:
|
||||
def test_point_buffer(self):
|
||||
pt = Point(0, 0)
|
||||
buf = buffer_geometry(pt, 1.0)
|
||||
assert buf.area > 3.0 # π * r² ≈ 3.14
|
||||
|
||||
def test_zero_distance_returns_point_like(self):
|
||||
pt = Point(0, 0)
|
||||
buf = buffer_geometry(pt, 0.0)
|
||||
# buffer(0) on point may return empty or point
|
||||
assert buf is not None
|
||||
|
||||
|
||||
class TestSetOperations:
|
||||
@pytest.fixture
|
||||
def poly_a(self):
|
||||
return Polygon([(0, 0), (2, 0), (2, 2), (0, 2)])
|
||||
|
||||
@pytest.fixture
|
||||
def poly_b(self):
|
||||
return Polygon([(1, 0), (3, 0), (3, 2), (1, 2)])
|
||||
|
||||
def test_intersection(self, poly_a, poly_b):
|
||||
result = intersect(poly_a, poly_b)
|
||||
assert result.area == pytest.approx(2.0)
|
||||
|
||||
def test_union(self, poly_a, poly_b):
|
||||
result = union(poly_a, poly_b)
|
||||
assert result.area == pytest.approx(6.0)
|
||||
|
||||
def test_difference(self, poly_a, poly_b):
|
||||
result = difference(poly_a, poly_b)
|
||||
assert result.area == pytest.approx(2.0)
|
||||
|
||||
def test_unary_union(self, poly_a, poly_b):
|
||||
result = unary_union([poly_a, poly_b])
|
||||
assert result.area == pytest.approx(6.0)
|
||||
|
||||
|
||||
class TestSpatialRelations:
|
||||
def test_contains_true(self):
|
||||
big = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
||||
small = Polygon([(1, 1), (2, 1), (2, 2), (1, 2)])
|
||||
assert contains(big, small) is True
|
||||
|
||||
def test_within(self):
|
||||
big = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
|
||||
small = Polygon([(1, 1), (2, 1), (2, 2), (1, 2)])
|
||||
assert within(small, big) is True
|
||||
|
||||
def test_distance(self):
|
||||
p1 = Point(0, 0)
|
||||
p2 = Point(3, 4)
|
||||
assert distance_between(p1, p2) == pytest.approx(5.0)
|
||||
81
tests/test_io.py
Normal file
81
tests/test_io.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""tests/test_io.py —— IO 读写单元测试。"""
|
||||
|
||||
import pytest
|
||||
import geopandas as gpd
|
||||
from pathlib import Path
|
||||
|
||||
from geo_tools.io.readers import read_vector, read_gpkg, list_gpkg_layers, read_csv_points
|
||||
from geo_tools.io.writers import write_vector, write_gpkg, write_csv
|
||||
|
||||
|
||||
class TestReadVector:
|
||||
def test_read_geojson(self, tmp_geojson_path):
|
||||
gdf = read_vector(tmp_geojson_path)
|
||||
assert isinstance(gdf, gpd.GeoDataFrame)
|
||||
assert len(gdf) == 3
|
||||
assert gdf.crs is not None
|
||||
|
||||
def test_read_with_crs_reprojection(self, tmp_geojson_path):
|
||||
gdf = read_vector(tmp_geojson_path, crs="EPSG:3857")
|
||||
assert gdf.crs.to_epsg() == 3857
|
||||
|
||||
def test_read_nonexistent_raises(self, tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_vector(tmp_path / "nonexistent.geojson")
|
||||
|
||||
def test_read_unsupported_format_raises(self, tmp_path):
|
||||
bad_file = tmp_path / "data.xyz"
|
||||
bad_file.write_text("dummy")
|
||||
with pytest.raises(ValueError, match="不支持"):
|
||||
read_vector(bad_file)
|
||||
|
||||
|
||||
class TestWriteReadRoundtrip:
|
||||
def test_geojson_roundtrip(self, sample_points_gdf, tmp_output_dir):
|
||||
out = tmp_output_dir / "out.geojson"
|
||||
write_vector(sample_points_gdf, out)
|
||||
loaded = read_vector(out)
|
||||
assert len(loaded) == len(sample_points_gdf)
|
||||
assert list(loaded.columns) == list(sample_points_gdf.columns)
|
||||
|
||||
def test_gpkg_roundtrip(self, sample_points_gdf, tmp_output_dir):
|
||||
out = tmp_output_dir / "out.gpkg"
|
||||
write_gpkg(sample_points_gdf, out, layer="points")
|
||||
loaded = read_gpkg(out, layer="points")
|
||||
assert len(loaded) == len(sample_points_gdf)
|
||||
|
||||
def test_gpkg_multiple_layers(self, sample_points_gdf, sample_polygon_gdf, tmp_output_dir):
|
||||
out = tmp_output_dir / "multi.gpkg"
|
||||
write_gpkg(sample_points_gdf, out, layer="points")
|
||||
write_gpkg(sample_polygon_gdf, out, layer="polygons", mode="a")
|
||||
layers = list_gpkg_layers(out)
|
||||
assert "points" in layers
|
||||
assert "polygons" in layers
|
||||
|
||||
def test_csv_roundtrip(self, sample_points_gdf, tmp_output_dir):
|
||||
out = tmp_output_dir / "out.csv"
|
||||
write_csv(sample_points_gdf, out)
|
||||
# CSV 写出的是 WKT geometry 列,用 pandas 读回验证
|
||||
import pandas as pd
|
||||
df = pd.read_csv(out)
|
||||
assert "geometry" in df.columns # 存在 WKT 几何列
|
||||
assert len(df) == len(sample_points_gdf) # 行数一致
|
||||
# 再用 read_csv_points 以 WKT 模式读回
|
||||
from geo_tools.io.readers import _read_csv_vector
|
||||
from pathlib import Path
|
||||
gdf_back = _read_csv_vector(Path(out), wkt_col="geometry")
|
||||
assert len(gdf_back) == len(sample_points_gdf)
|
||||
|
||||
|
||||
class TestReadCsvPoints:
|
||||
def test_read_csv_with_latlon(self, tmp_path):
|
||||
import pandas as pd
|
||||
csv_path = tmp_path / "points.csv"
|
||||
pd.DataFrame({
|
||||
"longitude": [116.4, 121.5],
|
||||
"latitude": [39.9, 31.2],
|
||||
"name": ["北京", "上海"],
|
||||
}).to_csv(csv_path, index=False)
|
||||
gdf = read_csv_points(csv_path)
|
||||
assert len(gdf) == 2
|
||||
assert gdf.crs.to_epsg() == 4326
|
||||
21
tests/test_proj.py
Normal file
21
tests/test_proj.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import sys
|
||||
import os
|
||||
os.environ["OGR_ORGANIZE_POLYGONS"] = "SKIP"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# 添加项目根目录到路径
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import geo_tools
|
||||
from geo_tools.core import projection
|
||||
from geo_tools.config.project_enum import CRS
|
||||
|
||||
info = projection.get_crs_info(CRS.CGCS2000_6_DEGREE_ZONE_18.value)
|
||||
print(info)
|
||||
print(type(CRS.CGCS2000_3_DEGREE_ZONE_27))
|
||||
|
||||
# aa = geo_tools.read_vector(r"E:\@三普\@临时文件夹\样点异常值剔除\容县\异常样点数据\AB_outliers.shp")
|
||||
# projection.reproject_gdf(aa,CRS.CGCS2000_3_DEGREE_ZONE_37).to_file(r"E:\@三普\@临时文件夹\样点异常值剔除\容县\AB_ou.shp")
|
||||
100
tests/test_vector.py
Normal file
100
tests/test_vector.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""tests/test_vector.py —— 矢量操作单元测试。"""
|
||||
|
||||
import pytest
|
||||
import geopandas as gpd
|
||||
from shapely.geometry import Point
|
||||
|
||||
from geo_tools.core.vector import (
|
||||
add_area_column,
|
||||
clip_to_extent,
|
||||
dissolve_by,
|
||||
drop_invalid_geometries,
|
||||
explode_multipart,
|
||||
reproject,
|
||||
set_crs,
|
||||
spatial_join,
|
||||
)
|
||||
|
||||
|
||||
class TestReproject:
|
||||
def test_basic_reproject(self, sample_points_gdf):
|
||||
result = reproject(sample_points_gdf, "EPSG:3857")
|
||||
assert result.crs.to_epsg() == 3857
|
||||
assert len(result) == len(sample_points_gdf)
|
||||
|
||||
def test_reproject_preserves_count(self, sample_points_gdf):
|
||||
result = reproject(sample_points_gdf, "EPSG:4490")
|
||||
assert len(result) == 3
|
||||
|
||||
def test_reproject_no_crs_raises(self):
|
||||
gdf = gpd.GeoDataFrame(geometry=[Point(0, 0)]) # 没有 CRS
|
||||
with pytest.raises(ValueError, match="CRS"):
|
||||
reproject(gdf, "EPSG:4326")
|
||||
|
||||
|
||||
class TestSetCrs:
|
||||
def test_set_crs_on_new_gdf(self):
|
||||
gdf = gpd.GeoDataFrame(geometry=[Point(116.4, 39.9)])
|
||||
result = set_crs(gdf, "EPSG:4326")
|
||||
assert result.crs.to_epsg() == 4326
|
||||
|
||||
def test_overwrite_blocked_by_default(self):
|
||||
gdf = gpd.GeoDataFrame(geometry=[Point(0, 0)], crs="EPSG:4326")
|
||||
with pytest.raises(ValueError, match="overwrite"):
|
||||
set_crs(gdf, "EPSG:3857")
|
||||
|
||||
def test_overwrite_allowed(self):
|
||||
gdf = gpd.GeoDataFrame(geometry=[Point(0, 0)], crs="EPSG:4326")
|
||||
result = set_crs(gdf, "EPSG:3857", overwrite=True)
|
||||
assert result.crs.to_epsg() == 3857
|
||||
|
||||
|
||||
class TestClipToExtent:
|
||||
def test_clip_by_bbox(self, sample_points_gdf):
|
||||
# 只包含北京(116.4, 39.9)的 bbox
|
||||
result = clip_to_extent(sample_points_gdf, (115.0, 38.0, 118.0, 41.0))
|
||||
assert len(result) == 1
|
||||
|
||||
def test_clip_by_geodataframe(self, sample_points_gdf, sample_polygon_gdf):
|
||||
# polygon 覆盖 115-122E,38-41N,应该包含北京
|
||||
result = clip_to_extent(sample_points_gdf, sample_polygon_gdf)
|
||||
assert len(result) >= 1
|
||||
|
||||
|
||||
class TestDissolveBy:
|
||||
def test_dissolve_by_field(self, sample_multi_polygon_gdf):
|
||||
gdf = sample_multi_polygon_gdf.copy()
|
||||
gdf["group"] = ["X", "X"] # 两条都归入同一组
|
||||
result = dissolve_by(gdf, by="group")
|
||||
assert len(result) == 1
|
||||
|
||||
def test_dissolve_preserves_crs(self, sample_multi_polygon_gdf):
|
||||
gdf = sample_multi_polygon_gdf.copy()
|
||||
gdf["group"] = ["A", "B"]
|
||||
result = dissolve_by(gdf, by="group")
|
||||
assert result.crs == gdf.crs
|
||||
|
||||
|
||||
class TestAddAreaColumn:
|
||||
def test_area_column_added(self, sample_polygon_gdf):
|
||||
result = add_area_column(sample_polygon_gdf, col_name="area_m2")
|
||||
assert "area_m2" in result.columns
|
||||
assert result["area_m2"].iloc[0] > 0
|
||||
|
||||
|
||||
class TestDropInvalidGeometries:
|
||||
def test_drop_invalid(self):
|
||||
from shapely.geometry import Polygon
|
||||
valid = Polygon([(0, 0), (1, 0), (1, 1), (0, 1)])
|
||||
invalid = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)]) # 蝴蝶形
|
||||
gdf = gpd.GeoDataFrame(geometry=[valid, invalid], crs="EPSG:4326")
|
||||
result = drop_invalid_geometries(gdf)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_fix_invalid(self):
|
||||
from shapely.geometry import Polygon
|
||||
invalid = Polygon([(0, 0), (1, 1), (1, 0), (0, 1)])
|
||||
gdf = gpd.GeoDataFrame(geometry=[invalid], crs="EPSG:4326")
|
||||
result = drop_invalid_geometries(gdf, fix=True)
|
||||
assert len(result) == 1
|
||||
assert result.geometry.is_valid.all()
|
||||
Reference in New Issue
Block a user