181 lines
5.9 KiB
Go
181 lines
5.9 KiB
Go
package windviz
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"predictor-refactored/internal/weather"
|
|
)
|
|
|
|
// constWind is a WindField returning a fixed sample everywhere.
|
|
type constWind struct {
|
|
u, v float64
|
|
epoch time.Time
|
|
}
|
|
|
|
func (c constWind) Wind(_ float64, _, _, _ float64) (weather.Sample, error) {
|
|
return weather.Sample{U: c.u, V: c.v}, nil
|
|
}
|
|
func (c constWind) Epoch() time.Time { return c.epoch }
|
|
func (c constWind) Source() string { return "test" }
|
|
|
|
func TestRasterizeGlobalDropsDuplicateColumn(t *testing.T) {
|
|
f := constWind{u: 5, v: -3, epoch: time.Unix(0, 0)}
|
|
out, err := Rasterize(f, Request{MinLng: 0, MaxLng: 360, Step: 90})
|
|
if err != nil {
|
|
t.Fatalf("Rasterize: %v", err)
|
|
}
|
|
if len(out) != 2 {
|
|
t.Fatalf("expected 2 components, got %d", len(out))
|
|
}
|
|
u := out[0]
|
|
// 360/90 = 4 columns (no duplicate 360°); lat -90..90 step 90 = 3 rows.
|
|
if u.Header.Nx != 4 || u.Header.Ny != 3 {
|
|
t.Errorf("grid = %dx%d, want 4x3", u.Header.Nx, u.Header.Ny)
|
|
}
|
|
if len(u.Data) != 12 {
|
|
t.Errorf("data len = %d, want 12", len(u.Data))
|
|
}
|
|
if u.Header.La1 != 90 || u.Header.La2 != -90 {
|
|
t.Errorf("lat range = %v..%v, want 90..-90 (north first)", u.Header.La1, u.Header.La2)
|
|
}
|
|
if u.Header.Lo1 != 0 || u.Header.Lo2 != 270 {
|
|
t.Errorf("lng range = %v..%v, want 0..270", u.Header.Lo1, u.Header.Lo2)
|
|
}
|
|
for _, d := range u.Data {
|
|
if d != 5 {
|
|
t.Errorf("U data = %v, want 5", d)
|
|
break
|
|
}
|
|
}
|
|
if out[0].Header.ParameterNumber != 2 || out[1].Header.ParameterNumber != 3 {
|
|
t.Errorf("component order should be U(2) then V(3)")
|
|
}
|
|
}
|
|
|
|
func TestRasterizeSignedLongitudeConvention(t *testing.T) {
|
|
f := constWind{u: 1, v: 2, epoch: time.Unix(0, 0)}
|
|
|
|
// A [-180, 180] global request must be detected as global and tiled
|
|
// without a duplicate seam column, identical to a 0..360 request.
|
|
signed, err := Rasterize(f, Request{MinLng: -180, MaxLng: 180, Step: 90})
|
|
if err != nil {
|
|
t.Fatalf("signed-global Rasterize: %v", err)
|
|
}
|
|
if signed[0].Header.Nx != 4 {
|
|
t.Errorf("signed-global nx = %d, want 4 (no duplicate column)", signed[0].Header.Nx)
|
|
}
|
|
|
|
// A western-hemisphere box must not 400; its western edge folds into [0,360).
|
|
west, err := Rasterize(f, Request{MinLat: 10, MaxLat: 20, MinLng: -100, MaxLng: -50, Step: 10})
|
|
if err != nil {
|
|
t.Fatalf("western-box Rasterize: %v", err)
|
|
}
|
|
if west[0].Header.Lo1 != 260 {
|
|
t.Errorf("western-box lo1 = %v, want 260 (=-100 folded)", west[0].Header.Lo1)
|
|
}
|
|
}
|
|
|
|
func TestRasterizeStepClamp(t *testing.T) {
|
|
f := constWind{epoch: time.Unix(0, 0)}
|
|
// step below min gets clamped, not rejected.
|
|
if _, err := Rasterize(f, Request{MinLat: -1, MaxLat: 1, MinLng: 0, MaxLng: 2, Step: 0.01}); err != nil {
|
|
t.Fatalf("Rasterize with tiny step: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCacheRoundTrip(t *testing.T) {
|
|
c := NewCache(2, time.Minute)
|
|
if _, ok := c.Get("a"); ok {
|
|
t.Errorf("empty cache should miss")
|
|
}
|
|
c.Put("a", Field{})
|
|
if _, ok := c.Get("a"); !ok {
|
|
t.Errorf("cache should hit after put")
|
|
}
|
|
}
|
|
|
|
// horizonWind has data up to horizon seconds and fails past it, the way a real
|
|
// dataset's time axis does.
|
|
type horizonWind struct{ horizon float64 }
|
|
|
|
func (w horizonWind) Wind(t, _, _, _ float64) (weather.Sample, error) {
|
|
if t > w.horizon {
|
|
return weather.Sample{}, fmt.Errorf("hour=%v out of range", t/3600)
|
|
}
|
|
return weather.Sample{U: 7, V: -3}, nil
|
|
}
|
|
func (w horizonWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
|
|
func (w horizonWind) Source() string { return "test" }
|
|
|
|
// northOnlyWind has data only in the northern hemisphere, the way a regional
|
|
// subset does. Its failures are genuinely per-cell.
|
|
type northOnlyWind struct{}
|
|
|
|
func (northOnlyWind) Wind(_, lat, _, _ float64) (weather.Sample, error) {
|
|
if lat < 0 {
|
|
return weather.Sample{}, fmt.Errorf("lat=%v outside region", lat)
|
|
}
|
|
return weather.Sample{U: 5, V: 1}, nil
|
|
}
|
|
func (northOnlyWind) Epoch() time.Time { return time.Unix(0, 0).UTC() }
|
|
func (northOnlyWind) Source() string { return "test" }
|
|
|
|
// TestRasterizeRefusesWhenNoCellHasData is the case the package comment already
|
|
// promised and the code never implemented ("a time outside coverage is a hard
|
|
// error").
|
|
//
|
|
// Time is one value for the whole request, so a time past the dataset's horizon
|
|
// fails every cell. Each failure was written as a zero, so the response was a
|
|
// complete grid of zero wind — a velocity layer draws that as an atmosphere at
|
|
// perfect rest. Missing data must not be rendered as calm air.
|
|
func TestRasterizeRefusesWhenNoCellHasData(t *testing.T) {
|
|
f := horizonWind{horizon: 600}
|
|
|
|
out, err := Rasterize(f, Request{Time: 700, MinLng: 0, MaxLng: 360, Step: 90})
|
|
|
|
if err == nil {
|
|
t.Fatalf("Rasterize returned %d components instead of an error", len(out))
|
|
}
|
|
if !strings.Contains(err.Error(), "out of range") {
|
|
t.Errorf("error %q does not carry the sampler's reason", err)
|
|
}
|
|
}
|
|
|
|
// TestRasterizeStillSucceedsWithinCoverage keeps the check above from rejecting
|
|
// healthy requests.
|
|
func TestRasterizeStillSucceedsWithinCoverage(t *testing.T) {
|
|
f := horizonWind{horizon: 600}
|
|
|
|
out, err := Rasterize(f, Request{Time: 300, MinLng: 0, MaxLng: 360, Step: 90})
|
|
if err != nil {
|
|
t.Fatalf("Rasterize within coverage: %v", err)
|
|
}
|
|
if got := out[0].Data[0]; got != 7 {
|
|
t.Errorf("u = %v, want 7", got)
|
|
}
|
|
}
|
|
|
|
// TestRasterizeZeroFillsIndividualGaps preserves the documented per-cell
|
|
// behaviour: a partly-covered grid is still worth drawing, so gaps stay zero and
|
|
// the request succeeds. Only a grid with nothing in it is refused.
|
|
func TestRasterizeZeroFillsIndividualGaps(t *testing.T) {
|
|
out, err := Rasterize(northOnlyWind{}, Request{
|
|
MinLat: -30, MaxLat: 30, MinLng: 0, MaxLng: 30, Step: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Rasterize partly-covered grid: %v", err)
|
|
}
|
|
|
|
// Row 0 is the northernmost latitude (+30), the last row is -30.
|
|
nx := out[0].Header.Nx
|
|
ny := out[0].Header.Ny
|
|
if got := out[0].Data[0]; got != 5 {
|
|
t.Errorf("northern cell u = %v, want 5", got)
|
|
}
|
|
if got := out[0].Data[(ny-1)*nx]; got != 0 {
|
|
t.Errorf("southern gap u = %v, want 0", got)
|
|
}
|
|
}
|