package kml import ( "encoding/xml" "math" "regexp" "strconv" "strings" "testing" "time" "b612.me/astro" "b612.me/astro/eclipse" "b612.me/astro/geojson" "b612.me/astro/moon" ) // 契约:调色板按 (event, role) 派发、role 分层、时间变 TimeStamp、环闭合与朝向、UT1 折回 UTC、LookAt 跨反经线。 func convert(t *testing.T, input string, options Options) kmlRoot { t.Helper() out, err := FromGeoJSON([]byte(input), options) if err != nil { t.Fatalf("FromGeoJSON: %v", err) } if !strings.HasPrefix(string(out), " 0 } // 契约:集合的时间窗口写成 Document/TimeSpan;NoTimes 去掉全部时间元素,得到不受客户端时间轴影响的静态叠加。 func TestDocumentTimeSpanAndNoTimes(t *testing.T) { sweep := feature("solar-eclipse", "visible-footprint-sweep", `{"type":"MultiLineString","coordinates":[[[100,20],[101,20]],[[102,21],[103,21]]]}`, `"times":[["2024-04-08T18:00:00Z","2024-04-08T18:05:00Z"],["2024-04-08T18:10:00Z","2024-04-08T18:15:00Z"]]`) marker := feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[121.5,31.2]}`, `"time":"2024-04-08T18:17:20Z","label":"02:17"`) input := collection(sweep, marker) root := convert(t, input, Options{}) if root.Document.TimeSpan == nil { t.Fatal("缺少 Document/TimeSpan") } if root.Document.TimeSpan.Begin != "2024-04-08T18:00:00Z" || root.Document.TimeSpan.End != "2024-04-08T18:17:20Z" { t.Fatalf("TimeSpan = %+v", root.Document.TimeSpan) } if len(placemarksOfRole(root, "visible-footprint-sweep")) != 2 { t.Fatal("带时间时应逐段拆 Placemark") } staticOut, err := FromGeoJSON([]byte(input), Options{NoTimes: true}) if err != nil { t.Fatalf("FromGeoJSON: %v", err) } if text := string(staticOut); strings.Contains(text, "") || strings.Contains(text, "") || strings.Contains(text, "") { t.Fatalf("NoTimes 仍写出时间元素:%s", text) } var static kmlRoot if err := xml.Unmarshal(staticOut, &static); err != nil { t.Fatalf("解析输出的 KML 失败:%v", err) } if static.Document.TimeSpan != nil { t.Fatalf("NoTimes 不该有 TimeSpan:%+v", static.Document.TimeSpan) } items := placemarksOfRole(static, "visible-footprint-sweep") if len(items) != 2 { t.Fatalf("NoTimes 仍应保留几何:%d", len(items)) } if items[0].Name != "" { t.Fatalf("NoTimes 下没有 label 的段不该再拿时刻当名字:%q", items[0].Name) } // 集合里没有时间时不写 TimeSpan。 plain := convert(t, collection(feature("solar-eclipse", "center-line", lineGeometry, "")), Options{}) if plain.Document.TimeSpan != nil { t.Fatalf("无时间集合不该有 TimeSpan:%+v", plain.Document.TimeSpan) } } func TestUT1LabelsAreConvertedToUTC(t *testing.T) { label := time.Date(2024, 4, 8, 18, 17, 20, 0, time.UTC) // 2024 年的 DUT1 只有十几毫秒:秒以下精度必须保留,否则这次折算在输出里完全看不见。 want := astro.UTCFromUT1(label).Format(time.RFC3339Nano) if !strings.Contains(want, ".") { t.Fatalf("样例应带小数秒以覆盖精度保留:%s", want) } input := `{"type":"FeatureCollection","time_scale":"UT1","features":[` + feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[121.5,31.2]}`, `"time":"`+label.Format(time.RFC3339)+`"`) + `]}` root := convert(t, input, Options{}) items := placemarksOfRole(root, "time-marker") if len(items) != 1 || items[0].TimeStamp == nil || items[0].TimeStamp.When != want { t.Fatalf("UT1 折回 UTC 后 = %+v, want %s", items, want) } if !strings.Contains(root.Document.Description, "UT1") { t.Fatalf("说明里应记录原口径:%q", root.Document.Description) } found := false for _, entry := range root.Document.ExtendedData.Data { if entry.Name == "time_scale" && entry.Value == "UT1" { found = true } } if !found { t.Fatal("ExtendedData 缺少 time_scale") } } // 契约:中性面图层默认只出轮廓,FillContext 才填充,NoFill 压过两者。 func TestFillPolicy(t *testing.T) { band := feature("solar-eclipse", "partial-band", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "") input := collection(band, feature("solar-eclipse", "central-band", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "")) plain := convert(t, input, Options{}) if got := styleByID(t, plain, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Fill != 0 { t.Fatalf("中性面默认必须显式 fill=0:%+v", got) } if got := styleByID(t, plain, "style-solar-eclipse-central-band").PolyStyle; got == nil || got.Color != redFill { t.Fatalf("中心带应有红色填充:%+v", got) } filled := convert(t, input, Options{FillContext: true}) if got := styleByID(t, filled, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Color != grayFill { t.Fatalf("FillContext 应给中性面灰色填充:%+v", got) } custom := convert(t, input, Options{ FillContext: true, Styles: map[string]Style{ "partial-band": {FillColor: "8000ff00"}, "central-band": {NoFill: true}, }, }) if got := styleByID(t, custom, "style-solar-eclipse-partial-band").PolyStyle; got == nil || got.Color != "8000ff00" { t.Fatalf("自定义填充未生效:%+v", got) } if got := styleByID(t, custom, "style-solar-eclipse-central-band").PolyStyle; got == nil || got.Fill != 0 { t.Fatalf("NoFill 应压成 fill=0:%+v", got) } } // 契约:SkipRoles 整层丢弃,图层、样式与 LookAt 都不再包含该 role。 func TestSkipRoles(t *testing.T) { input := collection( feature("solar-eclipse", "center-line", lineGeometry, ""), feature("solar-eclipse", "partial-footprint", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, ""), ) root := convert(t, input, Options{SkipRoles: []string{"partial-footprint"}}) for _, folder := range root.Document.Folders { if strings.Contains(folder.Name, "偏食足迹") { t.Fatalf("被跳过的 role 仍出图层:%q", folder.Name) } } for _, style := range root.Document.Styles { if strings.HasSuffix(style.ID, "partial-footprint") { t.Fatalf("被跳过的 role 仍出样式:%s", style.ID) } } if got := len(placemarksOfRole(root, "center-line")); got != 1 { t.Fatalf("保留图层被误删:%d", got) } } // 契约:存在中心线等路径图层时,LookAt 只按路径取景,不被跨半球的大区域拉成全球视角。 func TestLookAtPrefersPathRoles(t *testing.T) { globe := feature("solar-eclipse", "partial-band", `{"type":"Polygon","coordinates":[[[-170,-40],[170,-40],[170,40],[-170,40],[-170,-40]]]}`, "") path := feature("solar-eclipse", "center-line", `{"type":"LineString","coordinates":[[120,30],[140,40]]}`, "") root := convert(t, collection(globe, path), Options{}) if root.Document.LookAt == nil { t.Fatal("缺少 LookAt") } if lat := root.Document.LookAt.Latitude; lat < 34 || lat > 36 { t.Fatalf("纬度 = %v,应按中心线取景", lat) } if lon := root.Document.LookAt.Longitude; lon < 129 || lon > 131 { t.Fatalf("经度 = %v,应按中心线取景", lon) } single := feature("solar-eclipse", "magnitude-line", `{"type":"LineString","coordinates":[[100,10],[140,20]]}`, "") only := convert(t, collection(single), Options{}) if only.Document.LookAt == nil || math.Abs(only.Document.LookAt.Longitude-120) > 1 || math.Abs(only.Document.LookAt.Latitude-15) > 1 { t.Fatalf("无路径图层时应退回全部要素:%+v", only.Document.LookAt) } } func TestLookAtHandlesAntimeridian(t *testing.T) { input := collection(feature("lunar-occultation", "occultation-band", `{"type":"Polygon","coordinates":[[[179,10],[-179,10],[-179,12],[179,12],[179,10]]]}`, "")) root := convert(t, input, Options{}) if root.Document.LookAt == nil { t.Fatal("缺少 LookAt") } if math.Abs(root.Document.LookAt.Longitude) < 179 || math.Abs(root.Document.LookAt.Longitude) > 180 { t.Fatalf("跨反经线的中心经度 = %v,应贴近 ±180", root.Document.LookAt.Longitude) } if root.Document.LookAt.Latitude < 10 || root.Document.LookAt.Latitude > 12 { t.Fatalf("中心纬度 = %v", root.Document.LookAt.Latitude) } plain := convert(t, input, Options{NoLookAt: true}) if plain.Document.LookAt != nil { t.Fatal("NoLookAt 时不应输出 LookAt") } } func TestFromGeoJSONRejectsBadInput(t *testing.T) { cases := []struct { name string input string }{ {"非 JSON", `{`}, {"非 FeatureCollection", `{"type":"Feature","geometry":null,"properties":{}}`}, {"缺少几何", collection(`{"type":"Feature","properties":{"role":"center-line"}}`)}, {"未知几何", collection(`{"type":"Feature","properties":{"role":"center-line"},"geometry":{"type":"CircularString","coordinates":[]}}`)}, {"times 与段数不齐", collection(feature("solar-eclipse", "visible-footprint-sweep", `{"type":"MultiLineString","coordinates":[[[100,20],[101,20]],[[102,21],[103,21]]]}`, `"times":[["2024-04-08T18:00:00Z","2024-04-08T18:05:00Z"]]`))}, {"纬度越界", collection(feature("solar-eclipse", "center-line", `{"type":"LineString","coordinates":[[100,95],[101,20]]}`, ""))}, {"非 UTC 时间", collection(feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[100,20]}`, `"time":"not-a-time"`))}, } for _, tc := range cases { if _, err := FromGeoJSON([]byte(tc.input), Options{}); err == nil { t.Errorf("%s:应当报错", tc.name) } } } func TestGeometryCollectionBecomesMultiGeometry(t *testing.T) { input := collection(feature("lunar-occultation", "occultation-band", `{"type":"GeometryCollection","geometries":[`+ `{"type":"MultiPolygon","coordinates":[[[[100,20],[101,20],[101,21],[100,20]]]]},`+ `{"type":"MultiLineString","coordinates":[[[102,22],[103,22]]]}]}`, "")) root := convert(t, input, Options{}) items := placemarksOfRole(root, "occultation-band") if len(items) != 1 || items[0].MultiGeometry == nil { t.Fatalf("GeometryCollection 应转成 MultiGeometry:%+v", items) } multi := items[0].MultiGeometry // 面自己另带一条描边折线(面只填充、不描边),加上集合里原有的线。 if len(multi.Polygons) != 1 || len(multi.Lines) != 2 { t.Fatalf("MultiGeometry 内容 = %d 面 / %d 线", len(multi.Polygons), len(multi.Lines)) } } // ringLongitudes 取某个 role 的所有外环经度序列。 func ringLongitudes(t *testing.T, root kmlRoot, role string) [][]float64 { t.Helper() rings := [][]float64{} for _, item := range placemarksOfRole(root, role) { polygons := []polygon{} if item.Polygon != nil { polygons = append(polygons, *item.Polygon) } if item.MultiGeometry != nil { polygons = append(polygons, item.MultiGeometry.Polygons...) } for _, shape := range polygons { if shape.OuterBoundaryIs == nil { continue } ring := []float64{} for _, token := range strings.Fields(shape.OuterBoundaryIs.Ring.Coordinates) { value, err := strconv.ParseFloat(strings.Split(token, ",")[0], 64) if err != nil { t.Fatalf("解析经度 %q:%v", token, err) } ring = append(ring, value) } rings = append(rings, ring) } } if len(rings) == 0 { t.Fatalf("%s 没有外环", role) } return rings } // 契约:本库 geojson 的输出已经按反经线拆好,转出的每条线与每个环都不得出现跨 180° 的经度边。 func TestLibraryOutputHasNoGlobeSpanningEdge(t *testing.T) { date := time.Date(2009, time.July, 22, 0, 0, 0, 0, time.UTC) partial, ok := eclipse.SolarEclipsePartialFootprints(date, eclipse.SolarEclipsePartialFootprintOptions{ Step: 20 * time.Minute, BoundaryPoints: 24, GreatestTimeStep: time.Hour, }) if !ok { t.Fatal("expected the 2009-07-22 partial phase") } data, err := geojson.MarshalSolarEclipse(partial, nil) if err != nil { t.Fatalf("MarshalSolarEclipse: %v", err) } out, err := FromGeoJSON(data, Options{}) if err != nil { t.Fatalf("转换本库 GeoJSON 失败:%v", err) } var root kmlRoot if err := xml.Unmarshal(out, &root); err != nil { t.Fatalf("输出不是合法 KML:%v", err) } for _, folder := range root.Document.Folders { for _, item := range folder.Placemarks { for _, coordinates := range coordinatesOf(item.geometry) { previous := math.NaN() for _, token := range strings.Fields(coordinates) { longitude, parseErr := strconv.ParseFloat(strings.Split(token, ",")[0], 64) if parseErr != nil { t.Fatalf("解析经度 %q:%v", token, parseErr) } if !math.IsNaN(previous) && math.Abs(longitude-previous) > 180 { t.Fatalf("%s 出现跨度 %.1f° 的边(%v → %v)", folder.Name, math.Abs(longitude-previous), previous, longitude) } previous = longitude } } } } } func coordinatesOf(shape geometry) []string { result := []string{} switch { case shape.Point != nil: result = append(result, shape.Point.Coordinates) case shape.LineString != nil: result = append(result, shape.LineString.Coordinates) case shape.Polygon != nil: result = append(result, ringCoordinates(*shape.Polygon)...) case shape.MultiGeometry != nil: for _, item := range shape.MultiGeometry.Points { result = append(result, item.Coordinates) } for _, item := range shape.MultiGeometry.Lines { result = append(result, item.Coordinates) } for _, item := range shape.MultiGeometry.Polygons { result = append(result, ringCoordinates(item)...) } } return result } // lineCoordinatesOf 只取折线坐标:面的环允许含 ±180 接缝段(那是填充边界), // 而面的样式写了 outline=0,客户端不会描它。 func lineCoordinatesOf(shape geometry) []string { result := []string{} switch { case shape.LineString != nil: result = append(result, shape.LineString.Coordinates) case shape.MultiGeometry != nil: for _, item := range shape.MultiGeometry.Lines { result = append(result, item.Coordinates) } } return result } func ringCoordinates(shape polygon) []string { result := []string{} if shape.OuterBoundaryIs != nil { result = append(result, shape.OuterBoundaryIs.Ring.Coordinates) } for _, inner := range shape.InnerBoundaries { result = append(result, inner.Ring.Coordinates) } return result } func TestFromGeoJSONAcceptsLibraryOutput(t *testing.T) { info := eclipse.ClosestLunarEclipse(time.Date(2029, time.January, 1, 0, 0, 0, 0, time.UTC)) data, err := geojson.MarshalLunarEclipse(info, 48) if err != nil { t.Fatalf("MarshalLunarEclipse: %v", err) } out, err := FromGeoJSON(data, Options{}) if err != nil { t.Fatalf("转换本库 GeoJSON 失败:%v", err) } var root kmlRoot if err := xml.Unmarshal(out, &root); err != nil { t.Fatalf("输出不是合法 KML:%v", err) } if len(root.Document.Folders) == 0 { t.Fatal("本库月食 GeoJSON 应至少产出一个图层") } if root.Document.LookAt == nil { t.Fatal("应输出 LookAt") } } // 契约补充(review 修复):LookAt.range 用米;Style 子元素按 KML sequence;样式 id 合法; // when 保留小数秒;SkipRoles 裁光全部要素时报错。 func TestLookAtRangeIsMeters(t *testing.T) { wide := feature("solar-eclipse", "center-line", `{"type":"LineString","coordinates":[[-60,10],[60,20]]}`, "") root := convert(t, collection(wide), Options{}) if root.Document.LookAt == nil { t.Fatal("缺少 LookAt") } // 120° 经度跨度约 1.2e7 m;若仍按千米写入会是 1e4 量级。 if rangeMeters := root.Document.LookAt.Range; rangeMeters < 1e7 { t.Fatalf("LookAt range = %v, 应使用米(≥1e7)", rangeMeters) } narrow := feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[121.5,31.2]}`, "") small := convert(t, collection(narrow), Options{}) if small.Document.LookAt == nil || small.Document.LookAt.Range < 2e5 { t.Fatalf("小要素的 LookAt range 下限应为 200 km:%+v", small.Document.LookAt) } } func TestStyleElementsFollowKMLSequence(t *testing.T) { input := collection(feature("solar-eclipse", "central-band", `{"type":"Polygon","coordinates":[[[100,20],[101,20],[101,21],[100,20]]]}`, "")) out, err := FromGeoJSON([]byte(input), Options{}) if err != nil { t.Fatal(err) } text := string(out) icon := strings.Index(text, "") line := strings.Index(text, "") poly := strings.Index(text, "") if icon < 0 || line < 0 || poly < 0 || !(icon < line && line < poly) { t.Fatalf("Style 子元素顺序应为 IconStyle", `{"type":"LineString","coordinates":[[0,0],[1,1]]}`, ""), feature("", "third-party-_-role-", `{"type":"LineString","coordinates":[[0,0],[1,1]]}`, ""), ) out, err := FromGeoJSON([]byte(input), Options{}) if err != nil { t.Fatal(err) } var root kmlRoot if err := xml.Unmarshal(out, &root); err != nil { t.Fatal(err) } valid := regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_.-]*$`) seen := map[string]bool{} for _, style := range root.Document.Styles { if !valid.MatchString(style.ID) { t.Errorf("样式 id 不是合法 NCName:%q", style.ID) } if seen[style.ID] { t.Errorf("样式 id 重复:%q", style.ID) } seen[style.ID] = true } if len(seen) != 2 { t.Fatalf("样式数 = %d, want 2", len(seen)) } if !strings.Contains(string(out), "#"+firstStyleID(t, root)) { t.Fatal("styleUrl 未引用清洗后的 id") } // 图层名仍应是原始 role,而不是清洗后的 id。 foundRaw := false for _, folder := range root.Document.Folders { if folder.Name == "third-party & " { foundRaw = true } } if !foundRaw { t.Fatalf("图层名未保留原始 role:%+v", root.Document.Folders) } } func firstStyleID(t *testing.T, root kmlRoot) string { t.Helper() if len(root.Document.Styles) == 0 { t.Fatal("缺少样式") } return root.Document.Styles[0].ID } func TestTimeStampsKeepSubSecondPrecision(t *testing.T) { input := collection(feature("solar-eclipse", "time-marker", `{"type":"Point","coordinates":[121.5,31.2]}`, `"time":"2024-04-08T18:17:20.123456789Z","label":"02:17"`)) root := convert(t, input, Options{}) items := placemarksOfRole(root, "time-marker") if len(items) != 1 || items[0].TimeStamp == nil { t.Fatalf("时间标记 = %+v", items) } if items[0].TimeStamp.When != "2024-04-08T18:17:20.123456789Z" { t.Fatalf("when = %q, 应保留小数秒", items[0].TimeStamp.When) } } func TestSkipRolesRejectsDroppingEverything(t *testing.T) { input := collection(feature("solar-eclipse", "partial-footprint", `{"type":"Polygon","coordinates":[[[100,20],[101,21],[102,20],[100,20]]]}`, "")) if _, err := FromGeoJSON([]byte(input), Options{SkipRoles: []string{"partial-footprint"}}); err == nil { t.Fatal("裁光全部要素时应报错,而不是输出空 Document") } } func TestMixedEventsPrefixFolderNames(t *testing.T) { input := collection( feature("solar-eclipse", "center-line", lineGeometry, ""), feature("lunar-occultation", "center-line", lineGeometry, ""), ) root := convert(t, input, Options{}) if len(root.Document.Folders) != 2 { t.Fatalf("图层数 = %d, want 2", len(root.Document.Folders)) } for _, folder := range root.Document.Folders { if !strings.Contains(folder.Name, " · ") { t.Fatalf("多事件集合的图层名应带事件前缀:%q", folder.Name) } } if root.Document.Name != "GeoJSON" { t.Fatalf("多事件集合的文档名不该只写首个事件:%q", root.Document.Name) } single := convert(t, collection(feature("solar-eclipse", "center-line", lineGeometry, "")), Options{}) if single.Document.Folders[0].Name != "中心线" { t.Fatalf("单事件集合不应加前缀:%q", single.Document.Folders[0].Name) } if single.Document.Name != "日食" { t.Fatalf("单事件集合的文档名应按事件类型:%q", single.Document.Name) } } func TestEmptyCollectionProducesDocumentWithoutLayers(t *testing.T) { out, err := FromGeoJSON([]byte(`{"type":"FeatureCollection","features":[]}`), Options{}) if err != nil { t.Fatalf("空集合应合法:%v", err) } var root kmlRoot if err := xml.Unmarshal(out, &root); err != nil { t.Fatal(err) } if len(root.Document.Folders) != 0 || len(root.Document.Styles) != 0 { t.Fatalf("空集合不该产生图层或样式:%+v", root.Document) } } // 契约:折线同样要在反经线接缝处断开。本库 geojson 的 band-outline 是"闭合环线", // 环上带 ±180 接缝段;只处理面的轮廓会让这条折线在换日线上画出一条直线。 func TestClosedLineSeamIsNotDrawn(t *testing.T) { input := collection(feature("lunar-occultation", "band-outline", `{"type":"MultiLineString","coordinates":[[[170,10],[180,10],[180,-10],[170,-10],[170,10]],[[-180,-10],[-170,-10],[-170,10],[-180,10],[-180,-10]]]}`, "")) root := convert(t, input, Options{}) items := placemarksOfRole(root, "band-outline") if len(items) != 1 { t.Fatalf("Placemark 数 = %d, want 1", len(items)) } texts := coordinatesOf(items[0].geometry) // 东侧环的接缝边在环中段,切成 2 段;西侧环的接缝边在收尾处,只切成 1 段:共 3 段。 if len(texts) != 3 { t.Fatalf("接缝切分后应有 3 段折线,得到 %d:%v", len(texts), texts) } for _, text := range texts { previous := math.NaN() previousLatitude := math.NaN() for _, token := range strings.Fields(text) { parts := strings.Split(token, ",") longitude, err := strconv.ParseFloat(parts[0], 64) if err != nil { t.Fatalf("解析经度 %q:%v", token, err) } latitude, err := strconv.ParseFloat(parts[1], 64) if err != nil { t.Fatalf("解析纬度 %q:%v", token, err) } if !math.IsNaN(previous) && math.Abs(math.Abs(previous)-180) < 1e-9 && math.Abs(math.Abs(longitude)-180) < 1e-9 && math.Abs(latitude-previousLatitude) > 1e-12 { t.Fatalf("折线上仍残留日界线段:%s", text) } previous, previousLatitude = longitude, latitude } } } // 端到端:真实星掩的 band-outline 跨反经线,转换后的 KML 不得出现沿 ±180 的直线段。 func TestLibraryOccultationOutputDrawsNoSeamLine(t *testing.T) { antares := moon.StarCoordinate{ ID: "Antares", RA: 247.3516666666667, Dec: -26.431944444444444, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, ProperMotionRACosDecMasPerYear: -10, ProperMotionDecMasPerYear: -20, ParallaxMas: 24, } paths, err := moon.FindStarOccultationPaths( time.Date(2026, time.February, 11, 0, 0, 0, 0, time.UTC), time.Date(2026, time.February, 12, 0, 0, 0, 0, time.UTC), antares, moon.OccultationPathOptions{Step: 10 * time.Minute, TargetSpacingKM: 200, DisableFootprints: true, DisableRiseSet: true}) if err != nil || len(paths) == 0 { t.Fatalf("FindStarOccultationPaths: paths=%d err=%v", len(paths), err) } data, err := geojson.MarshalStarOccultation(paths[0]) if err != nil { t.Fatalf("MarshalStarOccultation: %v", err) } out, err := FromGeoJSON(data, Options{}) if err != nil { t.Fatalf("FromGeoJSON: %v", err) } var root kmlRoot if err := xml.Unmarshal(out, &root); err != nil { t.Fatalf("输出不是合法 KML:%v", err) } seams := 0 for _, folder := range root.Document.Folders { for _, item := range folder.Placemarks { for _, coordinates := range lineCoordinatesOf(item.geometry) { previousLongitude, previousLatitude := math.NaN(), math.NaN() for _, token := range strings.Fields(coordinates) { parts := strings.Split(token, ",") longitude, parseErr := strconv.ParseFloat(parts[0], 64) if parseErr != nil { t.Fatalf("解析经度 %q:%v", token, parseErr) } latitude, parseErr := strconv.ParseFloat(parts[1], 64) if parseErr != nil { t.Fatalf("解析纬度 %q:%v", token, parseErr) } if !math.IsNaN(previousLongitude) && math.Abs(math.Abs(previousLongitude)-180) < 1e-9 && math.Abs(math.Abs(longitude)-180) < 1e-9 && math.Abs(latitude-previousLatitude) > 1e-12 { seams++ } previousLongitude, previousLatitude = longitude, latitude } } } } if seams != 0 { t.Fatalf("KML 里仍画出 %d 条日界线直线段", seams) } } // 契约:整条都落在 ±180 上的线是真实边界(例如极区被反经线裁出的边),原样保留而不是报错。 func TestLineOnTheSeamIsPreserved(t *testing.T) { input := collection(feature("lunar-occultation", "visibility-boundary", `{"type":"LineString","coordinates":[[180,-80],[180,-60],[180,-40]]}`, "")) root := convert(t, input, Options{}) items := placemarksOfRole(root, "visibility-boundary") if len(items) != 1 { t.Fatalf("Placemark 数 = %d, want 1", len(items)) } texts := coordinatesOf(items[0].geometry) if len(texts) != 1 { t.Fatalf("接缝上的线应原样保留 1 段,得到 %d", len(texts)) } if !strings.Contains(texts[0], "180,-80") || !strings.Contains(texts[0], "180,-40") { t.Fatalf("坐标未保留:%s", texts[0]) } } // 契约补充(review 修复):LookAt 子元素按 KML 2.2 sequence;单点路径片段跳过而不是让整份转换失败; // 多几何要素的标量 time 也变成 TimeStamp;环上除接缝外的真实边一条不少;任意 event 都支持样式覆盖。 func splitPositions(text string) [][2]float64 { result := [][2]float64{} for _, chunk := range strings.Fields(text) { parts := strings.Split(chunk, ",") if len(parts) < 2 { continue } longitude, firstErr := strconv.ParseFloat(parts[0], 64) latitude, secondErr := strconv.ParseFloat(parts[1], 64) if firstErr != nil || secondErr != nil { continue } result = append(result, [2]float64{longitude, latitude}) } return result } func TestLookAtElementsFollowKMLSequence(t *testing.T) { out, err := FromGeoJSON([]byte(collection(feature("solar-eclipse", "center-line", lineGeometry, ""))), Options{}) if err != nil { t.Fatal(err) } text := string(out) start := strings.Index(text, "") end := strings.Index(text, "") if start < 0 || end < start { t.Fatalf("缺少 LookAt:%s", text) } block := text[start:end] order := []int{} for _, name := range []string{"longitude", "latitude", "heading", "tilt", "range"} { order = append(order, strings.Index(block, "<"+name+">")) } for index, position := range order { if position < 0 { t.Fatalf("LookAt 缺少第 %d 个子元素:%s", index, block) } if index > 0 && position < order[index-1] { t.Fatalf("LookAt 子元素顺序应为 longitude"+name+"") { t.Fatalf("language %q: KML 缺少图层名 %q", tc.language, name) } } } }