package datasets import ( "context" "errors" "testing" "time" "predictor-refactored/internal/weather" ) // stubField is a WindField that carries only its epoch. type stubField struct{ epoch time.Time } func (f stubField) Wind(_, _, _, _ float64) (weather.Sample, error) { return weather.Sample{}, nil } func (f stubField) Epoch() time.Time { return f.epoch } func (f stubField) Source() string { return "fake" } // fakeSource records what it was asked to open and can simulate an unreachable // origin via latestErr. type fakeSource struct { latest time.Time latestErr error opened []DatasetID } func (s *fakeSource) ID() string { return "fake" } func (s *fakeSource) LatestEpoch(context.Context) (time.Time, error) { if s.latestErr != nil { return time.Time{}, s.latestErr } return s.latest, nil } func (s *fakeSource) Download(context.Context, DatasetID, Storage, ProgressSink, Throttle) error { return errors.New("fakeSource does not download") } func (s *fakeSource) Open(_ context.Context, id DatasetID, _ Storage) (weather.WindField, error) { s.opened = append(s.opened, id) return stubField{epoch: id.Epoch}, nil } func (s *fakeSource) Coverage(id DatasetID) Coverage { return Coverage{ Region: Region{MinLat: -90, MaxLat: 90, MinLng: 0, MaxLng: 360}, StartTime: id.Epoch, EndTime: id.Epoch.Add(192 * time.Hour), } } // fakeStore is an in-memory Storage holding a fixed set of committed datasets, // newest first, as LocalStore.List promises. type fakeStore struct{ ids []DatasetID } func (s *fakeStore) SourceID() string { return "fake" } func (s *fakeStore) Path(id DatasetID) string { return "/dev/null/" + id.Filename() } func (s *fakeStore) Exists(id DatasetID) bool { for _, have := range s.ids { if have.Equals(id) { return true } } return false } func (s *fakeStore) List() ([]DatasetID, error) { return s.ids, nil } func (s *fakeStore) Remove(DatasetID) error { return nil } func (s *fakeStore) BeginWrite(DatasetID) (TempHandle, error) { return nil, errors.New("not used") } func (s *fakeStore) Lock(context.Context) (func(), error) { return func() {}, nil } // TestRefreshLoadsAStoredDatasetOlderThanTheFreshnessTTL pins the distinction // the freshness TTL is allowed to make. // // A dataset on disk is usable wind data whatever its age. The TTL answers "go // fetch something newer?", not "may I read what is already here?". While both // questions shared one check, an archival run that was the only dataset present // got skipped, active stayed empty, and every prediction answered "no dataset // loaded" with gigabytes of usable data on disk — and, with no reachable origin, // forever. func TestRefreshLoadsAStoredDatasetOlderThanTheFreshnessTTL(t *testing.T) { old := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Hour) src := &fakeSource{latestErr: errors.New("origin unreachable")} store := &fakeStore{ids: []DatasetID{{Epoch: old}}} m := New(src, store, nil, nil) // The origin probe is expected to fail; loading what is on disk is not. _, _ = m.Refresh(context.Background(), 48*time.Hour) if m.Active() == nil { t.Fatal("Active() is nil: the stored dataset was never loaded") } if got := m.Active().Epoch(); !got.Equal(old) { t.Errorf("loaded epoch = %s, want %s", got, old) } } // TestRefreshPrefersTheNewestStoredDataset guards the ordering the fix relies // on: falling back to disk must not mean falling back to the oldest file there. func TestRefreshPrefersTheNewestStoredDataset(t *testing.T) { newer := time.Now().UTC().Add(-10 * 24 * time.Hour).Truncate(time.Hour) older := newer.Add(-20 * 24 * time.Hour) src := &fakeSource{latestErr: errors.New("origin unreachable")} store := &fakeStore{ids: []DatasetID{{Epoch: newer}, {Epoch: older}}} m := New(src, store, nil, nil) _, _ = m.Refresh(context.Background(), 48*time.Hour) if m.Active() == nil { t.Fatal("Active() is nil") } if got := m.Active().Epoch(); !got.Equal(newer) { t.Errorf("loaded epoch = %s, want the newest stored %s", got, newer) } } // TestFieldForLoadsAStoredDatasetThatIsNotActive covers asking for a specific // run by epoch — the archival-forecast case. Being stored is enough; the caller // should not have to make it the active dataset first. func TestFieldForLoadsAStoredDatasetThatIsNotActive(t *testing.T) { active := time.Now().UTC().Truncate(time.Hour) archive := active.Add(-30 * 24 * time.Hour) src := &fakeSource{latest: active} store := &fakeStore{ids: []DatasetID{{Epoch: active}, {Epoch: archive}}} m := New(src, store, nil, nil) if err := m.Load(context.Background(), DatasetID{Epoch: active}); err != nil { t.Fatalf("Load active: %v", err) } field, err := m.FieldFor(context.Background(), archive) if err != nil { t.Fatalf("FieldFor(%s): %v", archive, err) } if got := field.Epoch(); !got.Equal(archive) { t.Errorf("epoch = %s, want %s", got, archive) } // The active dataset must be unaffected: asking for an archival run for one // prediction should not repoint the service at it. if got := m.Active().Epoch(); !got.Equal(active) { t.Errorf("Active() moved to %s, want %s", got, active) } } // TestFieldForRejectsAnEpochThatIsNotStored is the half that matters most: a // requested dataset that cannot be served must say so rather than quietly // substituting a different one, which is what ignoring the parameter did. func TestFieldForRejectsAnEpochThatIsNotStored(t *testing.T) { active := time.Now().UTC().Truncate(time.Hour) src := &fakeSource{latest: active} store := &fakeStore{ids: []DatasetID{{Epoch: active}}} m := New(src, store, nil, nil) if err := m.Load(context.Background(), DatasetID{Epoch: active}); err != nil { t.Fatalf("Load: %v", err) } missing := active.Add(-365 * 24 * time.Hour) field, err := m.FieldFor(context.Background(), missing) if err == nil { t.Fatalf("FieldFor(%s) returned field with epoch %s, want an error", missing, field.Epoch()) } }