package svg import ( "encoding/xml" "errors" "io" "math" "strings" "testing" "time" "b612.me/astro/internal/svgmap" "b612.me/astro/moon" ) func TestFindStarOccultationSVGsHR4799(t *testing.T) { location := time.FixedZone("CST", 8*3600) diagrams, err := FindStarOccultationSVGs( time.Date(2025, 6, 5, 0, 0, 0, 0, location), time.Date(2025, 6, 6, 0, 0, 0, 0, location), hr4799StarCoordinate(), moon.OccultationPathOptions{Step: 5 * time.Minute, TargetSpacingKM: 200}, StarOccultationSVGOptions{Width: 720, Height: 520}, ) if err != nil { t.Fatalf("FindStarOccultationSVGs() error = %v", err) } if len(diagrams) != 1 { t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want 1", len(diagrams)) } diagram := diagrams[0] for _, want := range []string{ ` & title", SummaryText: "Custom summary", GreatestText: "Custom greatest", MapTitle: "Custom map", ContactsTitle: "Custom events", FooterNote: "Custom footer", }) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } for _, want := range []string{ "Custom <occultation> & title", "Custom summary", "Custom greatest", "Custom map", "Custom events", "Custom footer", "Start", "Greatest", "End", } { if !strings.Contains(diagram, want) { t.Fatalf("SVG missing %q", want) } } if strings.Contains(diagram, "Custom ") { t.Fatal("custom title was not XML escaped") } if err := validateXML(diagram); err != nil { t.Fatalf("generated SVG is not valid XML: %v", err) } } func TestStarOccultationPathSVGIncludesAlignedTimeLabels(t *testing.T) { diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{ Location: time.UTC, TimeLabelStep: 30 * time.Minute, }) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } for _, want := range []string{`class="occultation-time-marker"`, `>10:30`, `>12:30`} { if !strings.Contains(diagram, want) { t.Fatalf("stellar occultation SVG missing time marker %q", want) } } markers := occultationTimeMarkerPoints( sampleStarOccultationPath().CenterLine, 30*time.Minute, time.UTC, []time.Time{sampleStarOccultationPath().Start.Time, sampleStarOccultationPath().End.Time}, ) foundGreatestTime := false for _, marker := range markers { if marker.Time.Equal(sampleStarOccultationPath().Greatest.Time) { foundGreatestTime = true break } } if !foundGreatestTime { t.Fatal("aligned time at greatest was discarded instead of being placed below the event label") } } func TestStarOccultationPathSVGCanDisableTimeLabels(t *testing.T) { diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{TimeLabelStep: -1}) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } if strings.Contains(diagram, `class="occultation-time-marker"`) { t.Fatal("disabled occultation time labels were rendered") } } func TestStarOccultationPathSVGSplitsAntimeridian(t *testing.T) { diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{}) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } if got := strings.Count(diagram, `class="center-line"`); got != 2 { t.Fatalf("center-line segment count = %d, want 2", got) } if got := strings.Count(diagram, `class="occultation-band"`); got != 2 { t.Fatalf("occultation-band segment count = %d, want 2", got) } segments := starOccultationPathSegments(sampleStarOccultationPath().CenterLine) if len(segments) != 2 { t.Fatalf("path segment count = %d, want 2", len(segments)) } if segments[0][len(segments[0])-1].Longitude != 180 || segments[1][0].Longitude != -180 { t.Fatalf("antimeridian interpolation = %.3f / %.3f, want +180 / -180", segments[0][len(segments[0])-1].Longitude, segments[1][0].Longitude) } if !segments[0][len(segments[0])-1].Time.Equal(segments[1][0].Time) { t.Fatal("antimeridian split points do not share the interpolated time") } } func TestStarOccultationPathSVGUsesDetailedPhysicalLand(t *testing.T) { diagram, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{}) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } for _, want := range []string{ `class="land-layer"`, `class="land"`, `fill="#d8d9d2"`, `stroke="#a6aaa4"`, `fill-rule="evenodd"`, `vector-effect="non-scaling-stroke"`, "Natural Earth 1:50m", "不含行政边界", } { if !strings.Contains(diagram, want) { t.Fatalf("SVG missing detailed-land marker %q", want) } } if got := strings.Count(diagram, `class="land"`); got != 1 { t.Fatalf("land path count = %d, want one compact path", got) } if strings.Contains(diagram, `fill="#000`) || strings.Contains(diagram, `fill="black"`) { t.Fatal("land path uses a black fill") } } func TestStarOccultationPathSVGUsesNorthPolarProjection(t *testing.T) { path := sampleStarOccultationPath() path.Greatest.Latitude = 72 for index := range path.CenterLine { path.CenterLine[index].Latitude = 62 + float64(index)*4 } for index := range path.NorthernLimit { path.NorthernLimit[index].Latitude = 68 + float64(index)*3 path.SouthernLimit[index].Latitude = 58 + float64(index)*3 } path.Start.Latitude = path.NorthernLimit[0].Latitude path.End.Latitude = path.NorthernLimit[len(path.NorthernLimit)-1].Latitude diagram, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) if err != nil { t.Fatalf("StarOccultationPathSVG() error = %v", err) } for _, want := range []string{` 179 } } if !hasWest || !hasEast { t.Fatalf("split band does not cover both map edges: west=%v east=%v", hasWest, hasEast) } } func TestFindStarOccultationSVGsNoEvent(t *testing.T) { diagrams, err := FindStarOccultationSVGs( time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 2, 0, 0, 0, 0, time.UTC), moon.StarCoordinate{ ID: "polar-star", RA: 0, Dec: 89, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameICRS, }, moon.OccultationPathOptions{}, StarOccultationSVGOptions{}, ) if err != nil { t.Fatalf("FindStarOccultationSVGs() error = %v", err) } if len(diagrams) != 0 { t.Fatalf("FindStarOccultationSVGs() returned %d diagrams, want none", len(diagrams)) } } func TestStarOccultationPathSVGRejectsInvalidPath(t *testing.T) { path := sampleStarOccultationPath() path.Greatest.Longitude = math.NaN() _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) if !errors.Is(err, ErrInvalidStarOccultationPath) { t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err) } } func TestStarOccultationPathSVGRejectsMisalignedLimits(t *testing.T) { path := sampleStarOccultationPath() path.SouthernLimit[1].Time = path.SouthernLimit[1].Time.Add(time.Second) _, err := StarOccultationPathSVG(path, StarOccultationSVGOptions{}) if !errors.Is(err, ErrInvalidStarOccultationPath) { t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationPath", err) } } func TestStarOccultationPathSVGRejectsCanvasTooSmall(t *testing.T) { _, err := StarOccultationPathSVG(sampleStarOccultationPath(), StarOccultationSVGOptions{Width: 1, Height: 1}) if !errors.Is(err, ErrInvalidStarOccultationSVGOptions) { t.Fatalf("StarOccultationPathSVG() error = %v, want ErrInvalidStarOccultationSVGOptions", err) } } func hr4799StarCoordinate() moon.StarCoordinate { return moon.StarCoordinate{ ID: "HR 4799", RA: 189.1975, Dec: -5.831944444444, Epoch: time.Date(2000, 1, 1, 12, 0, 0, 0, time.UTC), Frame: moon.CoordinateFrameJ2000, ProperMotionRACosDecMasPerYear: -28, ProperMotionDecMasPerYear: -18, } } func sampleStarOccultationPath() moon.StarOccultationPath { start := time.Date(2026, 8, 2, 10, 0, 0, 0, time.UTC) center := []moon.OccultationPathPoint{ {Time: start, Longitude: 160, Latitude: 18, MoonAltitude: 5, WidthKM: 3200}, {Time: start.Add(time.Hour), Longitude: 175, Latitude: 10, MoonAltitude: 35, WidthKM: 3300}, {Time: start.Add(2 * time.Hour), Longitude: -175, Latitude: 1, MoonAltitude: 50, WidthKM: 3400}, {Time: start.Add(3 * time.Hour), Longitude: -160, Latitude: -8, MoonAltitude: 12, WidthKM: 3300}, } northern := make([]moon.OccultationPathPoint, len(center)) southern := make([]moon.OccultationPathPoint, len(center)) for index, point := range center { northern[index] = point northern[index].Latitude += 12 northern[index].WidthKM = 0 southern[index] = point southern[index].Latitude -= 12 southern[index].WidthKM = 0 } return moon.StarOccultationPath{ TargetID: "synthetic-star", Start: center[0], Greatest: center[1], End: center[3], Complete: true, CenterLine: center, NorthernLimit: northern, SouthernLimit: southern, Step: time.Hour, } } func validateXML(value string) error { decoder := xml.NewDecoder(strings.NewReader(value)) for { if _, err := decoder.Token(); err != nil { if errors.Is(err, io.EOF) { return nil } return err } } }