Files
astro/internal/geodata/polygon_union_test.go
T
b612 2bf8478639 feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
2026-09-17 12:27:40 +08:00

352 lines
11 KiB
Go

package geodata
import (
"math"
"strings"
"testing"
)
func TestPolygonUnionLatitudeIndexMatchesExactPointLocation(t *testing.T) {
rings, err := polygonUnionRings([][]GeoPoint{{
{Longitude: -7, Latitude: -2},
{Longitude: -1, Latitude: -6},
{Longitude: 5, Latitude: -3},
{Longitude: 8, Latitude: 2},
{Longitude: 3, Latitude: 7},
{Longitude: -4, Latitude: 5},
}})
if err != nil {
t.Fatalf("polygonUnionRings: %v", err)
}
ring := rings[0]
for latitudeIndex := -80; latitudeIndex <= 80; latitudeIndex++ {
for longitudeIndex := -100; longitudeIndex <= 100; longitudeIndex++ {
point := polygonUnionPoint{
x: float64(longitudeIndex) / 10,
y: float64(latitudeIndex) / 10,
}
got := polygonUnionPointLocationOnEdges(point, ring.locationEdges)
want := polygonUnionPointLocation(point, ring.points)
if got != want {
t.Fatalf("indexed point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want)
}
if got := polygonUnionPointLocationInRing(point, ring); got != want {
t.Fatalf("binned point location at %.1f, %.1f = %d, want %d", point.x, point.y, got, want)
}
}
}
for _, point := range ring.points {
if got := polygonUnionPointLocationOnEdges(point, ring.locationEdges); got != 0 {
t.Fatalf("indexed point location at boundary %+v = %d, want 0", point, got)
}
if got := polygonUnionPointLocationInRing(point, ring); got != 0 {
t.Fatalf("binned point location at boundary %+v = %d, want 0", point, got)
}
}
}
func TestUnionPolygonsMergesOverlappingRingsWithoutInternalEdges(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 0, Latitude: 0},
{Longitude: 3, Latitude: 0},
{Longitude: 3, Latitude: 2},
{Longitude: 0, Latitude: 2},
},
{
{Longitude: 2, Latitude: 1},
{Longitude: 4, Latitude: 1},
{Longitude: 4, Latitude: 3},
{Longitude: 2, Latitude: 3},
},
})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 1, Latitude: 1},
{Longitude: 2.5, Latitude: 2.5},
{Longitude: 3.5, Latitude: 1.5},
} {
if !sweepPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("union does not contain %+v", point)
}
}
if sweepPolygonContainsOrTouches(polygons[0], GeoPoint{Longitude: 1, Latitude: 2.5}) {
t.Fatal("union contains a point outside both inputs")
}
// 并集边界恰为两环的外轮廓:被另一环吞掉的角点 (3,2) 与 (2,1) 不得作为顶点留下,
// 否则说明内部边被当成边界保留。
wantVertices := []GeoPoint{
{Longitude: 0, Latitude: 0},
{Longitude: 3, Latitude: 0},
{Longitude: 3, Latitude: 1},
{Longitude: 4, Latitude: 1},
{Longitude: 4, Latitude: 3},
{Longitude: 2, Latitude: 3},
{Longitude: 2, Latitude: 2},
{Longitude: 0, Latitude: 2},
}
for _, want := range wantVertices {
if !ringHasVertex(polygons[0], want) {
t.Fatalf("union boundary lost the outline vertex %+v: %+v", want, polygons[0])
}
}
for _, swallowed := range []GeoPoint{
{Longitude: 3, Latitude: 2},
{Longitude: 2, Latitude: 1},
} {
if ringHasVertex(polygons[0], swallowed) {
t.Fatalf("union kept the swallowed corner %+v as a boundary vertex (internal edge): %+v", swallowed, polygons[0])
}
}
}
// ringHasVertex 判断环上是否存在与目标重合(1e-9 度)的顶点;环首尾重复只算一次。
func ringHasVertex(ring []GeoPoint, want GeoPoint) bool {
for _, point := range ring {
if math.Abs(point.Longitude-want.Longitude) <= 1e-9 && math.Abs(point.Latitude-want.Latitude) <= 1e-9 {
return true
}
}
return false
}
func TestUnionPolygonsPreservesDisjointAndContainedRings(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 0, Latitude: 4},
},
{
{Longitude: 1, Latitude: 1},
{Longitude: 2, Latitude: 1},
{Longitude: 2, Latitude: 2},
{Longitude: 1, Latitude: 2},
},
{
{Longitude: 10, Latitude: 0},
{Longitude: 11, Latitude: 0},
{Longitude: 11, Latitude: 1},
{Longitude: 10, Latitude: 1},
},
})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(polygons) != 2 {
t.Fatalf("polygon count=%d, want containing and disjoint rings", len(polygons))
}
}
func TestPolygonUnionContainmentChecksInputEdgeMidpoints(t *testing.T) {
result := [][]GeoPoint{{
{Longitude: 0, Latitude: 0},
{Longitude: 4, Latitude: 0},
{Longitude: 4, Latitude: 4},
{Longitude: 3, Latitude: 4},
{Longitude: 3, Latitude: 1},
{Longitude: 1, Latitude: 1},
{Longitude: 1, Latitude: 4},
{Longitude: 0, Latitude: 4},
}}
input := [][]GeoPoint{{
{Longitude: 0.5, Latitude: 3.5},
{Longitude: 3.5, Latitude: 3.5},
{Longitude: 0.5, Latitude: 3},
}}
for _, point := range input[0] {
if !sphericalPolygonContainsOrTouches(result[0], point) {
t.Fatalf("fixture input vertex is outside result: %+v", point)
}
}
if polygonUnionContainsInputs(result, input) {
t.Fatal("containment accepted an input edge crossing the result's open notch")
}
}
func TestUnionPolygonsMergesAcrossAntimeridian(t *testing.T) {
polygons, err := UnionPolygons([][]GeoPoint{
{
{Longitude: 170, Latitude: -5},
{Longitude: -175, Latitude: -5},
{Longitude: -175, Latitude: 5},
{Longitude: 170, Latitude: 5},
},
{
{Longitude: 175, Latitude: 0},
{Longitude: -170, Latitude: 0},
{Longitude: -170, Latitude: 10},
{Longitude: 175, Latitude: 10},
},
})
if err != nil {
t.Fatalf("UnionPolygons across antimeridian: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("antimeridian polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 172, Latitude: 0},
{Longitude: 179, Latitude: 3},
{Longitude: -172, Latitude: 7},
} {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("antimeridian union does not contain %+v", point)
}
}
}
func TestUnionPolygonsMergesOverlappingPolarCaps(t *testing.T) {
first := SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 72)
second := SphericalCircle(GeoPoint{Longitude: 120, Latitude: 86}, 7, 72)
polygons, err := UnionPolygons([][]GeoPoint{first, second})
if err != nil {
t.Fatalf("UnionPolygons at north pole: %v", err)
}
if len(polygons) != 1 {
t.Fatalf("polar polygon count=%d, want one", len(polygons))
}
for _, point := range []GeoPoint{
{Longitude: 0, Latitude: 86},
{Longitude: 120, Latitude: 86},
{Longitude: 0, Latitude: 90},
} {
if !sphericalPolygonContainsOrTouches(polygons[0], point) {
t.Fatalf("polar union does not contain %+v", point)
}
}
}
func TestUnionPolygonsPreservesPoleWindingBoundary(t *testing.T) {
// The minimum lies just after the ring's start. A planar closure across
// 360 degrees cuts off that minimum as a separate thin polygon.
source := []GeoPoint{
{Longitude: 31.55, Latitude: 3.79},
{Longitude: 31.78, Latitude: 3.76},
{Longitude: 32.02, Latitude: 3.74},
{Longitude: 32.51, Latitude: 3.77},
{Longitude: 33.02, Latitude: 3.86},
{Longitude: 60, Latitude: 20},
{Longitude: 120, Latitude: 50},
{Longitude: 179, Latitude: 70},
{Longitude: -120, Latitude: 75},
{Longitude: -60, Latitude: 55},
{Longitude: 0, Latitude: 15},
{Longitude: 30, Latitude: 4.14},
{Longitude: 31.45, Latitude: 3.81},
}
for _, south := range []bool{false, true} {
for _, reverse := range []bool{false, true} {
ring := append([]GeoPoint(nil), source...)
if south {
for index := range ring {
ring[index].Latitude = -ring[index].Latitude
}
}
if reverse {
reverseGeoPoints(ring)
}
if _, err := unionPolygonsPlanar([][]GeoPoint{ring}); err == nil {
t.Errorf("south=%v reverse=%v: planar union accepted a pole-winding ring", south, reverse)
}
polygons, err := UnionPolygons([][]GeoPoint{ring})
if err != nil || len(polygons) != 1 {
t.Fatalf("south=%v reverse=%v: rings=%d err=%v, want one intact ring", south, reverse, len(polygons), err)
}
if len(openGeoRing(polygons[0])) != len(ring) {
t.Fatalf("union changed a single pole-winding boundary: points=%d want=%d", len(openGeoRing(polygons[0])), len(ring))
}
line := append(append([]GeoPoint(nil), ring...), ring[0])
if miss := visibleLineworkBoundarySourceMissDistanceKM(polygons, [][]GeoPoint{line}); miss > 1e-3 {
t.Fatalf("pole-winding union leaves source boundary by %.6f km", miss)
}
}
}
}
func TestUnionPolygonsRejectsHoles(t *testing.T) {
// 4 根条围成方环:并集的外边界面积 4.0、内边界(洞)1.0,真实并集面积 3.0。
// 环列表表达不了孔洞,因此必须报错,而不是把内边界也当成实体面返回 4+1=5 的超集。
// Four bars forming a square frame: the union's outer boundary has area 4.0 and the
// inner one (a hole) 1.0, so the true union area is 3.0. A ring list cannot express
// the hole, so the union must fail instead of returning the inner boundary as a solid
// face and reporting the superset 4+1=5.
ring := func(x0, y0, x1, y1 float64) []GeoPoint {
return []GeoPoint{
{Longitude: x0, Latitude: y0},
{Longitude: x1, Latitude: y0},
{Longitude: x1, Latitude: y1},
{Longitude: x0, Latitude: y1},
}
}
bars := [][]GeoPoint{
ring(-1, 0.5, 1, 1),
ring(-1, -1, 1, -0.5),
ring(-1, -0.5, -0.5, 0.5),
ring(0.5, -0.5, 1, 0.5),
}
if _, err := UnionPolygons(bars); err == nil {
t.Fatal("UnionPolygons accepted a union with a hole instead of reporting it")
}
// 正对照:两个分离的方块仍是两个环,不应报错。
// Positive control: two disjoint squares stay two rings and must not fail.
disjoint := [][]GeoPoint{ring(0, 0, 1, 1), ring(3, 0, 4, 1)}
result, err := UnionPolygons(disjoint)
if err != nil {
t.Fatalf("disjoint union failed: %v", err)
}
if len(result) != 2 {
t.Fatalf("disjoint union rings=%d, want 2", len(result))
}
}
func TestPolygonUnionWindingPredictsPlanarFailure(t *testing.T) {
rings := map[string][]GeoPoint{
"square": {{0, 0}, {4, 0}, {4, 4}, {0, 4}},
"equatorial": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 0}, 30, 64),
"north cap": SphericalCircle(GeoPoint{Longitude: 0, Latitude: 86}, 7, 64),
"south cap": SphericalCircle(GeoPoint{Longitude: 120, Latitude: -86}, 7, 64),
}
for name, ring := range rings {
_, err := unionPolygonsPlanar([][]GeoPoint{ring})
reported := err != nil && strings.Contains(err.Error(), "winds around a chart pole")
if winding := polygonUnionRingWindsAroundPole(ring); winding != reported {
t.Fatalf("%s: winding=%v planar error=%v", name, winding, err)
}
}
}
func TestUnionPolygonsWindingInputMatchesSphericalChart(t *testing.T) {
cap := SphericalCircle(GeoPoint{Longitude: 40, Latitude: 86}, 8, 72)
want, err := unionPolygonsSphericalChart([][]GeoPoint{cap})
if err != nil {
t.Fatalf("unionPolygonsSphericalChart: %v", err)
}
got, err := UnionPolygons([][]GeoPoint{cap})
if err != nil {
t.Fatalf("UnionPolygons: %v", err)
}
if len(got) != len(want) {
t.Fatalf("ring count=%d, want %d", len(got), len(want))
}
for index := range want {
if len(got[index]) != len(want[index]) {
t.Fatalf("ring %d points=%d, want %d", index, len(got[index]), len(want[index]))
}
for pointIndex := range want[index] {
if !SameGeoPoint(got[index][pointIndex], want[index][pointIndex]) {
t.Fatalf("ring %d point %d=%+v, want %+v",
index, pointIndex, got[index][pointIndex], want[index][pointIndex])
}
}
}
}