package forecast import ( "context" "errors" "testing" "time" "github.com/itworx/pulse/internal/pool" "github.com/itworx/pulse/internal/share" ) type staticShares struct{ snapshot share.Snapshot } func (s staticShares) Snapshot(context.Context) (share.Snapshot, error) { return s.snapshot, nil } type staticPools struct{ snapshot pool.Snapshot } func (s staticPools) Snapshot(context.Context) (pool.Snapshot, error) { return s.snapshot, nil } type fullHistory struct{ points []Point } func (h fullHistory) Points(_ context.Context, _, _ string, _ time.Time, limit int) ([]Point, error) { if limit != len(h.points) { return nil, errors.New("history query did not reserve the current-point slot") } return h.points, nil } func TestStorageProviderProjectsQualifiedShareHistory(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) shareSnapshot := share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{ {ObservedAt: now.Add(-14 * 24 * time.Hour), UsedBytes: 200}, {ObservedAt: now.Add(-7 * 24 * time.Hour), UsedBytes: 300}, {ObservedAt: now, UsedBytes: 400}, }, }}} poolSnapshot := pool.Snapshot{Pools: []pool.Pool{{ID: " CACHE ", UsableBytes: 1000}}} provider := StorageProvider{Shares: staticShares{shareSnapshot}, Pools: staticPools{poolSnapshot}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }} snapshot, err := provider.Snapshot(context.Background()) if err != nil { t.Fatal(err) } if snapshot.QualifiedCount != 1 || len(snapshot.Items) != 1 || snapshot.Items[0].EntityID != "media" || snapshot.Items[0].ProjectedAt == nil || snapshot.Items[0].CapacityBytes != 1000 { t.Fatalf("qualified storage forecast missing: %+v", snapshot) } } func TestStorageProviderDoesNotCountInsufficientOrStaleHistory(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) base := share.Share{ID: "media", Name: "Media", UsedBytes: 400, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}, GrowthHistory: []share.GrowthPoint{{ObservedAt: now, UsedBytes: 400}}} provider := StorageProvider{Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{base}}}, Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }} insufficient, err := provider.Snapshot(context.Background()) if err != nil || insufficient.QualifiedCount != 0 || insufficient.Items[0].Reason != "insufficient_points" { t.Fatalf("insufficient history counted as forecast: %+v, %v", insufficient, err) } base.SizeState = share.SizeStale provider.Shares = staticShares{share.Snapshot{Source: share.Source{Freshness: share.Stale}, Shares: []share.Share{base}}} stale, err := provider.Snapshot(context.Background()) if err != nil || stale.QualifiedCount != 0 || stale.Items[0].Reason != "history_stale" || stale.Items[0].ProjectedAt != nil { t.Fatalf("stale history was not explicit: %+v, %v", stale, err) } } func TestStorageProviderEmptySourceNeverCreatesZeroByteEntity(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) provider := StorageProvider{Shares: staticShares{share.UnknownSnapshot(now, "shares", "unraid", "source_unavailable")}, Pools: staticPools{pool.Snapshot{}}, Policy: Policy{Enabled: true}, Now: func() time.Time { return now }} snapshot, err := provider.Snapshot(context.Background()) if err != nil || len(snapshot.Items) != 0 || snapshot.QualifiedCount != 0 || snapshot.Reason != "source_unavailable" { t.Fatalf("empty source created a forecast entity: %+v, %v", snapshot, err) } } func TestStorageProviderKeepsMatureHistoryInsidePointBound(t *testing.T) { now := time.Date(2026, 8, 12, 1, 0, 0, 0, time.UTC) history := make([]Point, 0, 4) for day := 4; day > 0; day-- { history = append(history, Point{ObservedAt: now.Add(-time.Duration(day) * 24 * time.Hour), UsedBytes: uint64((5 - day) * 100)}) } provider := StorageProvider{ Shares: staticShares{share.Snapshot{Source: share.Source{Freshness: share.Fresh}, Shares: []share.Share{{ID: "media", Name: "Media", UsedBytes: 500, SizeObservedAt: now, SizeState: share.SizeAvailable, Placements: []share.Placement{{PoolID: "cache"}}}}}}, Pools: staticPools{pool.Snapshot{Pools: []pool.Pool{{ID: "cache", UsableBytes: 1000}}}}, History: fullHistory{points: history}, Policy: Policy{Enabled: true, MaxPoints: 5}, Now: func() time.Time { return now }, } snapshot, err := provider.Snapshot(context.Background()) if err != nil || snapshot.QualifiedCount != 1 || snapshot.Items[0].DataPoints != 5 { t.Fatalf("mature history exceeded its bound: %+v, %v", snapshot, err) } }