diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index bbc5d10..a2542fd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -53,4 +53,4 @@ jobs: poetry run flake8 . --count --exit-zero --max-complexity=10 --max-line-length=120 --statistics - name: Test with pytest run: | - poetry run pytest --verbose -p no:warnings -m "not heavy" ./tests/test_drawing.py ./tests/test_geo.py ./tests/test_map.py ./tests/test_issues.py + poetry run pytest --verbose -p no:warnings -m "not heavy" ./tests/test_drawing.py ./tests/test_geo.py ./tests/test_map.py ./tests/test_issues.py ./tests/test_cli.py diff --git a/README.md b/README.md index 18e1562..c0f903a 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,31 @@ cnmaps export ./henan.shp --province 河南省 --level 省 --record first 输出格式默认按文件后缀推断:`.geojson` / `.json` 对应 GeoJSON,`.shp` 对应 ESRI Shapefile。 +### 检查并读取自定义边界文件 + +如果你有自己的 `GeoJSON` 或 `Shapefile`,也可以先把它整理成符合 `cnmaps boundary spec` 的结构,再交给 `cnmaps` 检查和读取。 + +```bash +cnmaps check-boundary ./my-boundary.geojson +cnmaps check-boundary ./my-boundary.shp --json # 将检查结果以 JSON 输出 +``` + +检查通过后,就可以在 Python 代码中读取为 `MapPolygon`,继续用于 `make_mask_array(...)`、`maskout(...)` 或 `clip_*`: + +```python +from cnmaps import read_boundary_file + +boundary = read_boundary_file("./my-boundary.geojson") +``` + +如果你的原始 `shp` / `geojson` 还不符合这套规范,推荐先安装 `cnmaps` 自带的 AI Skill,再向 AI 发送类似下面的提示词: + +```text +帮我把 转为符合 cnmaps 可识别格式的 shapefile/geojson 文件,并通过 cnmaps 的 check-boundary 检查。 +``` + +这样 AI 会更容易按 `cnmaps boundary spec` 整理文件结构;整理完成后,再执行 `cnmaps check-boundary ...` 验证即可。 + ## 使用指南 针对本项目更多的使用方法,我们还有一份更详细的文档:[cnmaps使用指南](https://cnmaps.readthedocs.io/zh_CN/latest/index.html) diff --git a/cnmaps/_bundled_skills/platforms/codex/cnmaps-python-assistant/references/api-cheatsheet.md b/cnmaps/_bundled_skills/platforms/codex/cnmaps-python-assistant/references/api-cheatsheet.md index 919f192..29029eb 100644 --- a/cnmaps/_bundled_skills/platforms/codex/cnmaps-python-assistant/references/api-cheatsheet.md +++ b/cnmaps/_bundled_skills/platforms/codex/cnmaps-python-assistant/references/api-cheatsheet.md @@ -104,6 +104,22 @@ Good for: - resolving a user's abbreviated or ambiguous region name before calling `get_adm_maps` - batch-filtering names after the user already knows several exact region names +### `validate_boundary_file(fp, allow_multi_feature=True)` + +Use when the user has an external GeoJSON or Shapefile and wants to know whether it matches the cnmaps boundary spec before using it for masking or clipping. + +Current boundary spec: + +- file suffix must be `.geojson`, `.json`, or `.shp` +- CRS must be WGS84 / `EPSG:4326` +- geometries must all be `Polygon` or `MultiPolygon` +- empty or invalid geometries are rejected +- multiple features are allowed, but they are treated as one combined boundary when read + +### `read_boundary_file(fp, dissolve=True)` + +Use when the user already has an external boundary file that matches the cnmaps boundary spec and wants a `MapPolygon` for `make_mask_array(...)`, `maskout(...)`, or `clip_*`. + ## Drawing APIs ### `draw_map(map_polygon, ax=None, **kwargs)` @@ -211,6 +227,15 @@ Rules: - Output format is inferred from the destination suffix unless `--engine` is provided explicitly. - Default coordinates are WGS84; use `--gcj02` only when the user explicitly wants GCJ02 export. +### `cnmaps check-boundary [--json]` + +Use when the user has an external GeoJSON or Shapefile and wants a direct terminal check before reading it with `read_boundary_file(...)`. + +Rules: + +- Prefer this command when the user is unsure whether an external file already matches the cnmaps boundary spec. +- `--json` is useful when AI or another script will consume the result and decide how to rewrite the file; it changes the check result output format, not the input file format. + ## Rules Of Thumb - If the user wants China only: always write `country="中国", level="国"` explicitly. @@ -222,5 +247,6 @@ Rules: - If the user wants clipped scientific plots specifically for EPS/PS export, consider `simplify=True` on the clipping boundary to reduce path complexity. - If the user wants a raster mask array rather than a plotted figure: use `MapPolygon.make_mask_array(...)` or `MapPolygon.maskout(...)`. - If the user wants exported vector output: query `only_polygon=True` and use `map_polygon.to_file(...)`. +- If the user wants to use a custom GeoJSON or Shapefile for masking, do not assume arbitrary files will work directly; first validate them against the cnmaps boundary spec, then read them with `read_boundary_file(...)`. - If the user wants global country boundaries: `get_adm_maps(level="国")` is now the correct broad query. - If the user asks about seams, gaps, or disputed-border behavior in world maps, explain the source-semantic caveat instead of blaming plotting code first. diff --git a/cnmaps/_bundled_skills/shared/cnmaps-python-assistant/references/workflows.md b/cnmaps/_bundled_skills/shared/cnmaps-python-assistant/references/workflows.md index 3b53a58..8da3eef 100644 --- a/cnmaps/_bundled_skills/shared/cnmaps-python-assistant/references/workflows.md +++ b/cnmaps/_bundled_skills/shared/cnmaps-python-assistant/references/workflows.md @@ -32,6 +32,7 @@ - Important: be explicit about what `cnmaps` handles directly versus what remains a downstream raster-processing step. - If the user wants a boolean-like mask array, use `MapPolygon.make_mask_array(lons, lats)`. - If the user wants masked data back, use `MapPolygon.maskout(lons, lats, data)`. +- If the user wants to use a custom GeoJSON or Shapefile instead of built-in administrative boundaries, first validate it against the cnmaps boundary spec, then load it with `read_boundary_file(...)` and continue with the same `MapPolygon`-based masking workflow. ## Matplotlib Artist Clipping diff --git a/cnmaps/maps.py b/cnmaps/maps.py index 1fc918f..4bb4506 100644 --- a/cnmaps/maps.py +++ b/cnmaps/maps.py @@ -4,7 +4,10 @@ import re import warnings from collections.abc import Iterable +from dataclasses import dataclass from functools import lru_cache +from pathlib import Path +from typing import Optional import numpy as np import shapely.geometry as sgeom @@ -30,6 +33,30 @@ class MapNotFoundError(Exception): pass +class BoundarySpecError(ValueError): + """外部边界文件不符合 cnmaps boundary spec 时抛出的异常。""" + + pass + + +@dataclass(frozen=True) +class BoundaryCheckResult: + """Structured result for validating an external boundary file.""" + + path: str + passed: bool + driver: Optional[str] + feature_count: int + geometry_types: tuple[str, ...] + crs: Optional[str] + errors: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + @property + def ok(self) -> bool: + return self.passed + + class MapRecord(dict): """支持点号访问的地图记录对象。 @@ -208,6 +235,149 @@ def _clone_geometry(geom): return wkb.loads(geom.wkb) +def _is_supported_boundary_suffix(path: Path) -> bool: + return path.suffix.lower() in {".geojson", ".json", ".shp"} + + +def validate_boundary_file(fp, *, allow_multi_feature=True) -> BoundaryCheckResult: + """ + 检查外部 GeoJSON / Shapefile 是否符合 cnmaps boundary spec。 + + 当前规范要求: + - 文件格式为 GeoJSON / Shapefile + - CRS 必须明确且可等价为 WGS84 (EPSG:4326) + - 所有几何都必须是 Polygon / MultiPolygon + - 不能包含空几何 + - 几何必须有效 + """ + import geopandas as gpd + from pyproj import CRS + + path = Path(fp).expanduser().resolve() + errors = [] + warnings_list = [] + + if not path.exists(): + return BoundaryCheckResult( + path=str(path), + passed=False, + driver=None, + feature_count=0, + geometry_types=(), + crs=None, + errors=(f"文件不存在: {path}",), + ) + + if not _is_supported_boundary_suffix(path): + errors.append("仅支持符合 cnmaps boundary spec 的 .geojson/.json 或 .shp 文件") + + try: + gdf = gpd.read_file(path) + except Exception as exc: + return BoundaryCheckResult( + path=str(path), + passed=False, + driver=None, + feature_count=0, + geometry_types=(), + crs=None, + errors=(f"无法读取边界文件: {exc}",), + ) + + driver = getattr(gdf, "_driver", None) + feature_count = len(gdf) + if feature_count == 0: + errors.append("文件中不包含任何 feature") + + if not allow_multi_feature and feature_count > 1: + errors.append("文件包含多个 feature;当前模式下只允许单个 feature") + elif feature_count > 1: + warnings_list.append("文件包含多个 feature;读取时会先合并为一个统一边界") + + if gdf.crs is None: + errors.append("文件缺少 CRS 定义;cnmaps boundary spec 要求显式声明 WGS84 (EPSG:4326)") + crs_text = None + else: + crs_text = str(gdf.crs) + try: + if not CRS.from_user_input(gdf.crs).equals(CRS.from_epsg(4326)): + errors.append("文件 CRS 不是 WGS84 (EPSG:4326)") + except Exception as exc: + errors.append(f"无法解析 CRS: {exc}") + + if "geometry" not in gdf: + errors.append("文件中缺少 geometry 列") + geometry_types = () + else: + geometries = gdf.geometry + if geometries.isna().any(): + errors.append("文件包含空几何") + + non_null_geometries = [geom for geom in geometries if geom is not None] + geometry_types = tuple(sorted({geom.geom_type for geom in non_null_geometries})) + + unsupported = sorted( + { + geom_type + for geom_type in geometry_types + if geom_type not in {"Polygon", "MultiPolygon"} + } + ) + if unsupported: + errors.append( + "仅支持 Polygon / MultiPolygon 面几何,当前检测到: " + ", ".join(unsupported) + ) + + invalid_count = sum(1 for geom in non_null_geometries if not geom.is_valid) + if invalid_count: + errors.append(f"文件中包含 {invalid_count} 个无效几何") + + empty_count = sum(1 for geom in non_null_geometries if geom.is_empty) + if empty_count: + errors.append(f"文件中包含 {empty_count} 个空几何") + + return BoundaryCheckResult( + path=str(path), + passed=not errors, + driver=driver, + feature_count=feature_count, + geometry_types=geometry_types, + crs=crs_text, + errors=tuple(errors), + warnings=tuple(warnings_list), + ) + + +def read_boundary_file(fp, *, dissolve=True) -> "MapPolygon": + """ + 读取符合 cnmaps boundary spec 的外部边界文件并返回 MapPolygon。 + + 当前支持 GeoJSON / Shapefile,要求输入文件: + - CRS 为 WGS84 (EPSG:4326) + - 仅包含 Polygon / MultiPolygon + - 不包含空几何和无效几何 + """ + import geopandas as gpd + from shapely.ops import unary_union + + result = validate_boundary_file(fp) + if not result.ok: + raise BoundarySpecError(" ; ".join(result.errors)) + + gdf = gpd.read_file(Path(fp).expanduser().resolve()) + geometries = [geom for geom in gdf.geometry if geom is not None and not geom.is_empty] + + if dissolve: + merged = unary_union(geometries) + return _as_mappolygon_result(merged) + + polygons = [] + for geom in geometries: + normalized = _as_mappolygon_result(geom) + polygons.extend(list(normalized.geom.geoms)) + return MapPolygon(polygons) + + def _as_mappolygon_result(geom): """Normalize Shapely set-operation results to MapPolygon.""" if geom is None or geom.is_empty: diff --git a/cnmaps_cli/__init__.py b/cnmaps_cli/__init__.py index c45680e..8c3a97c 100644 --- a/cnmaps_cli/__init__.py +++ b/cnmaps_cli/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import argparse +import json import shutil from pathlib import Path +from typing import Optional SKILL_NAME = "cnmaps-python-assistant" @@ -281,7 +283,7 @@ def install_claudecode_skill(workspace: Path, force: bool = False, scope: str = ) -def _normalize_export_engine(engine: str | None, output_path: Path) -> str: +def _normalize_export_engine(engine: Optional[str], output_path: Path) -> str: if engine is None: suffix = output_path.suffix.lower() if suffix in {".geojson", ".json"}: @@ -314,7 +316,7 @@ def export_adm_maps( record: str = "all", wgs84: bool = True, simplify: bool = False, - engine: str | None = None, + engine: Optional[str] = None, encoding: str = "utf-8", ) -> Path: from cnmaps import get_adm_maps @@ -349,6 +351,23 @@ def export_adm_maps( return output +def check_boundary_file(path: Path) -> tuple[bool, dict]: + from cnmaps import validate_boundary_file + + result = validate_boundary_file(path) + payload = { + "path": result.path, + "passed": result.passed, + "driver": result.driver, + "feature_count": result.feature_count, + "geometry_types": list(result.geometry_types), + "crs": result.crs, + "errors": list(result.errors), + "warnings": list(result.warnings), + } + return result.passed, payload + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="cnmaps") subparsers = parser.add_subparsers(dest="command") @@ -430,10 +449,21 @@ def build_parser() -> argparse.ArgumentParser: help="Simplify geometries before export.", ) + check_parser = subparsers.add_parser( + "check-boundary", + help="Validate whether an external GeoJSON or Shapefile matches the cnmaps boundary spec.", + ) + check_parser.add_argument("path", type=Path, help="Boundary file path to validate.") + check_parser.add_argument( + "--json", + action="store_true", + help="Emit the validation result as JSON.", + ) + return parser -def main(argv: list[str] | None = None) -> int: +def main(argv=None) -> int: parser = build_parser() args = parser.parse_args(argv) @@ -478,5 +508,26 @@ def _unwrap(values): parser.exit(0, f"Exported administrative boundaries to {output}\n") + if args.command == "check-boundary": + passed, payload = check_boundary_file(args.path.expanduser().resolve()) + if args.json: + parser.exit(0 if passed else 1, json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + + lines = [ + f"Boundary spec check: {'PASS' if passed else 'FAIL'}", + f"- path: {payload['path']}", + f"- feature_count: {payload['feature_count']}", + f"- geometry_types: {', '.join(payload['geometry_types']) if payload['geometry_types'] else '(none)'}", + f"- crs: {payload['crs'] or '(missing)'}", + ] + if payload["warnings"]: + lines.append("- warnings:") + lines.extend(f" - {item}" for item in payload["warnings"]) + if payload["errors"]: + lines.append("- errors:") + lines.extend(f" - {item}" for item in payload["errors"]) + + parser.exit(0 if passed else 1, "\n".join(lines) + "\n") + parser.print_help() return 1 diff --git a/docs/source/content/api-ref.rst b/docs/source/content/api-ref.rst index 42378c9..cd5dfbe 100644 --- a/docs/source/content/api-ref.rst +++ b/docs/source/content/api-ref.rst @@ -108,6 +108,44 @@ maps模块主要存放与地图边界对象相关的类和函数。 cnmaps.maps.MapPolygon +.. py:function:: validate_boundary_file(fp, allow_multi_feature=True) + :module: cnmaps.maps + + 检查一个外部 ``GeoJSON`` / ``Shapefile`` 是否符合 ``cnmaps boundary spec``。 + + 当前规范要求: + + - 文件格式为 ``.geojson`` / ``.json`` / ``.shp`` + - CRS 必须明确且等价为 WGS84(``EPSG:4326``) + - 几何必须全部为 ``Polygon`` 或 ``MultiPolygon`` + - 不能包含空几何或无效几何 + + :param fp: + 待检查的边界文件路径。 + :param bool allow_multi_feature: + 是否允许文件包含多个 feature。默认为 ``True``;若允许,读取时会先合并为一个统一边界。 + + :return: + 结构化检查结果对象,包含是否通过、geometry 类型、CRS、错误列表与警告列表等信息。 + + +.. py:function:: read_boundary_file(fp, dissolve=True) + :module: cnmaps.maps + + 读取一个符合 ``cnmaps boundary spec`` 的外部 ``GeoJSON`` / ``Shapefile`` 文件,并将其转换为 ``MapPolygon``。 + + :param fp: + 边界文件路径。 + :param bool dissolve: + 是否在读取时先将多个 feature 合并为一个统一边界。默认为 ``True``。 + + :return: + 可直接用于 ``make_mask_array``、``maskout``、``clip_*`` 等工作流的 ``MapPolygon``。 + + :raises BoundarySpecError: + 当文件不符合 ``cnmaps boundary spec`` 时抛出。 + + .. py:function:: get_adm_names(province: str = None, city: str = None, district: str = None, level: str = '省', country: str = None, source: str = None, provider: str = None) :module: cnmaps.maps @@ -406,3 +444,12 @@ cli - ``--simplify``:导出前先简化几何 其中各类筛选规则尽量与 ``get_adm_maps`` 保持一致;如果需要多个名称筛选,可在同一个参数后依次写出多个值。 + +.. option:: check-boundary PATH [--json] + + 检查一个外部边界文件是否符合 ``cnmaps boundary spec``。 + + - ``PATH``:待检查的 ``GeoJSON`` / ``Shapefile`` 文件路径 + - ``--json``:输出结构化 JSON 检查结果;这只是检查结果的输出格式,与输入文件格式无关 + + 若检查通过,文件即可进一步通过 ``read_boundary_file(...)`` 读取并转换为 ``MapPolygon``。 diff --git a/docs/source/content/cli.rst b/docs/source/content/cli.rst index b8ae3ea..31690e7 100644 --- a/docs/source/content/cli.rst +++ b/docs/source/content/cli.rst @@ -19,6 +19,7 @@ - ``install-skill`` - ``export`` +- ``check-boundary`` 如果你想查看命令行程序自动生成的帮助信息,也可以执行: @@ -27,6 +28,7 @@ cnmaps -h cnmaps install-skill -h cnmaps export -h + cnmaps check-boundary -h 这些帮助信息由参数定义自动生成,通常会与当前版本保持同步;不过你通常不需要先看它们,本页已经给出常见用法与参数说明。 @@ -169,3 +171,40 @@ - 想在 shell 里直接把边界筛选并导出 - 不想为了简单导出任务专门写一段 Python 脚本 - 想把 ``get_adm_maps`` 的筛选逻辑复用到命令行工作流中 + +``check-boundary`` 子命令 +--------------------------- + +``check-boundary`` 用于检查一个外部 ``GeoJSON`` / ``Shapefile`` 是否符合 ``cnmaps boundary spec``,从而可以被 ``read_boundary_file(...)`` 稳定读取并转换为 ``MapPolygon``。 + +命令形式: + +.. code-block:: bash + + cnmaps check-boundary [--json] + +位置参数: + +- ````:待检查的边界文件路径 + +常用参数: + +- ``--json``:以 JSON 形式输出结构化检查结果,便于脚本或 AI 消费;这只是检查结果的输出格式,与输入文件本身是 ``shp`` 还是 ``geojson`` 无关 + +当前规范要求: + +- 文件格式为 ``.geojson`` / ``.json`` / ``.shp`` +- CRS 必须明确且等价为 WGS84(``EPSG:4326``) +- 几何必须全部为 ``Polygon`` 或 ``MultiPolygon`` +- 不能包含空几何或无效几何 + +如果文件包含多个 feature,``cnmaps`` 在读取时会先将它们合并为一个统一边界。 + +常见示例: + +.. code-block:: bash + + cnmaps check-boundary ./my-boundary.geojson + cnmaps check-boundary ./my-boundary.shp --json # 将检查结果以 JSON 输出 + +如果检查通过,就可以继续在 Python 代码中调用 ``read_boundary_file(...)`` 读取,并进一步执行 ``make_mask_array(...)``、``maskout(...)`` 或 ``clip_*`` 等操作。 diff --git a/docs/source/content/usage.rst b/docs/source/content/usage.rst index 45b3ca4..f12de9d 100644 --- a/docs/source/content/usage.rst +++ b/docs/source/content/usage.rst @@ -676,3 +676,45 @@ cnmaps 支持将查询到的矢量边界输出为 GeoJSON 或 ESRI Shapefile 文 china.to_file('./china.geojson') # 默认为 geojson 格式文件 china.to_file('./china.shp', engine='ESRI Shapefile') # 也可以指定 shapefile 格式文件 + + +读取自定义边界文件 +-------------------- + +除了内置行政边界以外,``cnmaps`` 也支持读取“符合 ``cnmaps boundary spec`` 的外部 GeoJSON / Shapefile 文件”,并将其转换为 ``MapPolygon``,继续用于 ``make_mask_array``、``maskout``、``clip_*`` 等工作流。 + +当前这套 boundary spec 的核心要求是: + +- 文件格式为 ``.geojson`` / ``.json`` / ``.shp`` +- CRS 必须明确且等价为 WGS84(``EPSG:4326``) +- 几何必须全部为 ``Polygon`` 或 ``MultiPolygon`` +- 不能包含空几何或无效几何 + +如果文件包含多个 feature,``cnmaps`` 会在读取时先将它们合并为一个统一边界。 + +推荐先用命令行检查文件是否符合规范: + +.. code:: bash + + cnmaps check-boundary ./my-boundary.geojson + cnmaps check-boundary ./my-boundary.shp --json # 将检查结果以 JSON 输出 + +检查通过后,就可以在 Python 代码中读取并继续做掩膜: + +.. code:: python + + from cnmaps import read_boundary_file + + boundary = read_boundary_file("./my-boundary.geojson") + mask = boundary.make_mask_array(lons, lats) + masked = boundary.maskout(lons, lats, data) + +如果你的原始 ``shp`` / ``geojson`` 还不符合这套规范,推荐先借助 AI 或 GIS 工具把它整理为符合 ``cnmaps boundary spec`` 的结构,再交给 ``cnmaps`` 检查和读取。 + +如果你希望借助 AI 来完成这一步,推荐先按 :doc:`installation` 中的说明安装 ``cnmaps`` 自带的 AI Skill,再向 AI 发送类似下面的提示词: + +.. code:: text + + 帮我把 转为符合 cnmaps 可识别格式的 shapefile/geojson 文件,并通过 cnmaps 的 check-boundary 检查。 + +这样 AI 会更容易按照 ``cnmaps boundary spec`` 去整理文件结构;整理完成后,再执行 ``cnmaps check-boundary ...`` 验证是否通过即可。 diff --git a/tests/test_cli.py b/tests/test_cli.py index 48e1dd9..a423ada 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,9 @@ import os import fiona +import geopandas as gpd import pytest +import shapely.geometry as sgeom from cnmaps_cli import main @@ -85,3 +87,83 @@ def test_cli_export_requires_supported_suffix_or_engine(capsys): assert excinfo.value.code == 1 captured = capsys.readouterr() assert "Unable to infer export format" in captured.err + + +def test_cli_check_boundary_passes_for_exported_geojson(capsys): + _ensure_tmp_dir() + path = os.path.join(TMP_CLI_EXPORT_DIR, "check-jingjin.geojson") + + with pytest.raises(SystemExit) as excinfo: + main( + [ + "export", + str(path), + "--province", + "北京市", + "天津市", + "--level", + "省", + ] + ) + + assert excinfo.value.code == 0 + + with pytest.raises(SystemExit) as excinfo: + main(["check-boundary", str(path)]) + + assert excinfo.value.code == 0 + captured = capsys.readouterr() + assert "Boundary spec check: PASS" in captured.err + assert "check-jingjin.geojson" in captured.err + + +def test_cli_check_boundary_passes_for_exported_shapefile(capsys): + _ensure_tmp_dir() + path = os.path.join(TMP_CLI_EXPORT_DIR, "check-henan.shp") + + with pytest.raises(SystemExit) as excinfo: + main( + [ + "export", + str(path), + "--province", + "河南省", + "--level", + "省", + "--record", + "first", + ] + ) + + assert excinfo.value.code == 0 + capsys.readouterr() + + with pytest.raises(SystemExit) as excinfo: + main(["check-boundary", str(path), "--json"]) + + assert excinfo.value.code == 0 + captured = capsys.readouterr() + payload = json.loads(captured.err) + assert payload["passed"] is True + assert payload["feature_count"] == 1 + assert payload["geometry_types"] == ["MultiPolygon"] + + +def test_cli_check_boundary_json_reports_failures(tmp_path, capsys): + path = tmp_path / "invalid-boundary.shp" + gdf = gpd.GeoDataFrame( + {"name": ["line"]}, + geometry=[sgeom.LineString([(0, 0), (1, 1)])], + crs="EPSG:3857", + ) + gdf.to_file(path, driver="ESRI Shapefile") + + with pytest.raises(SystemExit) as excinfo: + main(["check-boundary", str(path), "--json"]) + + assert excinfo.value.code == 1 + captured = capsys.readouterr() + payload = json.loads(captured.err) + assert payload["passed"] is False + assert any("WGS84" in item for item in payload["errors"]) + assert any("Polygon / MultiPolygon" in item for item in payload["errors"]) diff --git a/tests/test_map.py b/tests/test_map.py index cf936b3..f383709 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -11,13 +11,16 @@ from shapely.geometry.base import BaseGeometry from cnmaps import ( + BoundarySpecError, get_adm_maps, get_available_data_providers, get_data_provider, + read_boundary_file, read_mapjson, get_adm_names, MapNotFoundError, MapPolygon, + validate_boundary_file, ) from cnmaps.sample import load_dem import cnmaps.provider as provider_module @@ -149,6 +152,52 @@ def test_mappolygon_to_file(): fiona.open("./tmp/test_mappolygon_to_file/heilongjiang.shp") +def test_validate_boundary_file_and_read_boundary_file(tmp_path): + import geopandas as gpd + + geojson_path = tmp_path / "custom-boundary.geojson" + gdf = gpd.GeoDataFrame( + {"name": ["part-a", "part-b"]}, + geometry=[ + sgeom.Polygon([(113.0, 34.0), (114.0, 34.0), (114.0, 35.0), (113.0, 34.0)]), + sgeom.Polygon([(114.0, 34.0), (115.0, 34.0), (115.0, 35.0), (114.0, 34.0)]), + ], + crs="EPSG:4326", + ) + gdf.to_file(geojson_path, driver="GeoJSON") + + result = validate_boundary_file(geojson_path) + assert result.ok is True + assert result.feature_count == 2 + assert result.geometry_types == ("Polygon",) + assert "读取时会先合并为一个统一边界" in result.warnings[0] + + boundary = read_boundary_file(geojson_path) + assert isinstance(boundary, MapPolygon) + assert boundary.geom.geom_type == "MultiPolygon" + assert pytest.approx(boundary.geom.area) == 1.0 + + +def test_validate_boundary_file_rejects_non_wgs84_or_non_polygon(tmp_path): + import geopandas as gpd + + shp_path = tmp_path / "invalid-boundary.shp" + gdf = gpd.GeoDataFrame( + {"name": ["line-feature"]}, + geometry=[sgeom.LineString([(0, 0), (1, 1)])], + crs="EPSG:3857", + ) + gdf.to_file(shp_path, driver="ESRI Shapefile") + + result = validate_boundary_file(shp_path) + assert result.ok is False + assert any("WGS84" in error for error in result.errors) + assert any("Polygon / MultiPolygon" in error for error in result.errors) + + with pytest.raises(BoundarySpecError): + read_boundary_file(shp_path) + + def test_not_found(): """测试未找到预定义地图时是否抛出异常.""" with pytest.raises(MapNotFoundError):