predictor/internal/datasets/manager_test.go
2026-08-04 13:40:50 +09:00

166 lines
5.9 KiB
Go

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())
}
}