mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): add trend analysis and extended periods to dashboard summary
- Add `include_trends` query parameter to enable trend calculation - Implement trend comparison logic (delta % and direction) against previous periods - Add support for `last7d`, `last30d`, and `all` time period options - Update `DashboardSummaryResponse` to include optional `trends` field - Add helper functions for custom time window aggregation - Add unit tests for trend calculation and period window logic - Update feature documentation with new parameters and response schemas
This commit is contained in:
@@ -72,15 +72,39 @@
|
|||||||
|
|
||||||
| 参数 | 类型 | 说明 |
|
| 参数 | 类型 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `period` | string | 预设周期: `today`, `week`, `month` |
|
| `period` | string | 预设周期: `today`, `week`, `month`, `last7d`, `last30d`, `all` |
|
||||||
| `since` | int | 自定义起始时间 (Unix 秒) |
|
| `since` | int | 自定义起始时间 (Unix 秒) |
|
||||||
| `until` | int | 自定义结束时间 (Unix 秒) |
|
| `until` | int | 自定义结束时间 (Unix 秒) |
|
||||||
|
| `include_trends` | bool | 是否包含趋势数据 (与前一周期对比) |
|
||||||
|
|
||||||
> **已知限制** (待 fix-dashboard-summary-api 实现后解决):
|
**周期类型说明**:
|
||||||
> - `period=week` 使用自然周起点 (周一),非滚动 7 天
|
|
||||||
> - `period=month` 使用自然月起点 (1号),非滚动 30 天
|
| 周期值 | 时间窗口 | 趋势对比周期 |
|
||||||
> - 趋势数据 (delta/trend) 当前不返回,前端需隐藏趋势指示器
|
|--------|----------|--------------|
|
||||||
> - 建议使用 `since`/`until` 实现精确的滚动时间窗口
|
| `today` | 今日 00:00 UTC → 当前 | 昨日 |
|
||||||
|
| `week` | 本周一 00:00 UTC → 当前 | 上一自然周 |
|
||||||
|
| `month` | 本月 1 日 00:00 UTC → 当前 | 上一自然月 |
|
||||||
|
| `last7d` | 当前 - 7 天 → 当前 | 14 天前 → 7 天前 |
|
||||||
|
| `last30d` | 当前 - 30 天 → 当前 | 60 天前 → 30 天前 |
|
||||||
|
| `all` | 无时间过滤 | 不支持趋势 |
|
||||||
|
|
||||||
|
**趋势数据** (当 `include_trends=true` 时返回):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trends": {
|
||||||
|
"requests": { "delta": 12.5, "direction": "up" },
|
||||||
|
"tokens": { "delta": -1.1, "direction": "down" },
|
||||||
|
"error_rate": { "delta": 0.0, "direction": "stable" },
|
||||||
|
"latency": { "delta": -5.2, "direction": "down" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `delta` | 与前一周期相比的百分比变化 (四舍五入到 1 位小数) |
|
||||||
|
| `direction` | `up` (增长 >0.5%), `down` (下降 <-0.5%), `stable`, `new` (无基准数据) |
|
||||||
|
|
||||||
### 3.4 告警摘要
|
### 3.4 告警摘要
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -42,6 +44,52 @@ func (h *DashboardHandler) logBaseQuery() *gorm.DB {
|
|||||||
return logBaseQuery(h.logDBConn(), h.logPartitioner)
|
return logBaseQuery(h.logDBConn(), h.logPartitioner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrendInfo contains trend calculation results
|
||||||
|
type TrendInfo struct {
|
||||||
|
Delta *float64 `json:"delta,omitempty"` // Percentage change from previous period (nil if no baseline)
|
||||||
|
Direction string `json:"direction,omitempty"` // "up", "down", "stable", or "new" (no baseline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateTrend calculates the trend between current and previous values.
|
||||||
|
// Returns TrendInfo with delta percentage and direction.
|
||||||
|
func CalculateTrend(current, previous int64) TrendInfo {
|
||||||
|
if previous == 0 {
|
||||||
|
if current == 0 {
|
||||||
|
return TrendInfo{Direction: "stable"}
|
||||||
|
}
|
||||||
|
return TrendInfo{Direction: "new"}
|
||||||
|
}
|
||||||
|
delta := float64(current-previous) / float64(previous) * 100
|
||||||
|
// Round to 1 decimal place
|
||||||
|
delta = math.Round(delta*10) / 10
|
||||||
|
direction := "stable"
|
||||||
|
if delta > 0.5 {
|
||||||
|
direction = "up"
|
||||||
|
} else if delta < -0.5 {
|
||||||
|
direction = "down"
|
||||||
|
}
|
||||||
|
return TrendInfo{Delta: &delta, Direction: direction}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateTrendFloat calculates the trend between current and previous float values.
|
||||||
|
func CalculateTrendFloat(current, previous float64) TrendInfo {
|
||||||
|
if previous == 0 {
|
||||||
|
if current == 0 {
|
||||||
|
return TrendInfo{Direction: "stable"}
|
||||||
|
}
|
||||||
|
return TrendInfo{Direction: "new"}
|
||||||
|
}
|
||||||
|
delta := (current - previous) / previous * 100
|
||||||
|
delta = math.Round(delta*10) / 10
|
||||||
|
direction := "stable"
|
||||||
|
if delta > 0.5 {
|
||||||
|
direction = "up"
|
||||||
|
} else if delta < -0.5 {
|
||||||
|
direction = "down"
|
||||||
|
}
|
||||||
|
return TrendInfo{Delta: &delta, Direction: direction}
|
||||||
|
}
|
||||||
|
|
||||||
// RequestStats contains request-related statistics
|
// RequestStats contains request-related statistics
|
||||||
type RequestStats struct {
|
type RequestStats struct {
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
@@ -83,6 +131,14 @@ type TopModelStat struct {
|
|||||||
Tokens int64 `json:"tokens"`
|
Tokens int64 `json:"tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DashboardTrends contains trend data for dashboard metrics
|
||||||
|
type DashboardTrends struct {
|
||||||
|
Requests TrendInfo `json:"requests"`
|
||||||
|
Tokens TrendInfo `json:"tokens"`
|
||||||
|
ErrorRate TrendInfo `json:"error_rate"`
|
||||||
|
Latency TrendInfo `json:"latency"`
|
||||||
|
}
|
||||||
|
|
||||||
// DashboardSummaryResponse is the response for dashboard summary endpoint
|
// DashboardSummaryResponse is the response for dashboard summary endpoint
|
||||||
type DashboardSummaryResponse struct {
|
type DashboardSummaryResponse struct {
|
||||||
Period string `json:"period,omitempty"`
|
Period string `json:"period,omitempty"`
|
||||||
@@ -93,6 +149,7 @@ type DashboardSummaryResponse struct {
|
|||||||
Keys CountStats `json:"keys"`
|
Keys CountStats `json:"keys"`
|
||||||
ProviderKeys ProviderKeyStats `json:"provider_keys"`
|
ProviderKeys ProviderKeyStats `json:"provider_keys"`
|
||||||
TopModels []TopModelStat `json:"top_models"`
|
TopModels []TopModelStat `json:"top_models"`
|
||||||
|
Trends *DashboardTrends `json:"trends,omitempty"` // Only present when include_trends=true
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +159,10 @@ type DashboardSummaryResponse struct {
|
|||||||
// @Tags admin
|
// @Tags admin
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security AdminAuth
|
// @Security AdminAuth
|
||||||
// @Param period query string false "time period: today, week, month, all"
|
// @Param period query string false "time period: today, week, month, last7d, last30d, all"
|
||||||
// @Param since query int false "unix seconds"
|
// @Param since query int false "unix seconds"
|
||||||
// @Param until query int false "unix seconds"
|
// @Param until query int false "unix seconds"
|
||||||
|
// @Param include_trends query bool false "include trend data comparing to previous period"
|
||||||
// @Success 200 {object} DashboardSummaryResponse
|
// @Success 200 {object} DashboardSummaryResponse
|
||||||
// @Failure 400 {object} gin.H
|
// @Failure 400 {object} gin.H
|
||||||
// @Failure 500 {object} gin.H
|
// @Failure 500 {object} gin.H
|
||||||
@@ -116,6 +174,9 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse include_trends parameter
|
||||||
|
includeTrends := c.Query("include_trends") == "true"
|
||||||
|
|
||||||
// Build log query with time range
|
// Build log query with time range
|
||||||
logQuery := h.logBaseQuery()
|
logQuery := h.logBaseQuery()
|
||||||
logQuery = applyStatsRange(logQuery, rng)
|
logQuery = applyStatsRange(logQuery, rng)
|
||||||
@@ -235,6 +296,32 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate trends if requested
|
||||||
|
var trends *DashboardTrends
|
||||||
|
if includeTrends && rng.Period != "" && rng.Period != "all" {
|
||||||
|
prevStart, prevEnd := previousPeriodWindow(rng.Period)
|
||||||
|
if !prevStart.IsZero() && !prevEnd.IsZero() {
|
||||||
|
prevStats, err := h.aggregateFromLogRecords(prevStart, prevEnd)
|
||||||
|
if err == nil {
|
||||||
|
prevErrorRate := 0.0
|
||||||
|
if prevStats.Requests > 0 {
|
||||||
|
prevErrorRate = float64(prevStats.Failed) / float64(prevStats.Requests)
|
||||||
|
}
|
||||||
|
prevAvgLatency := 0.0
|
||||||
|
if prevStats.Requests > 0 {
|
||||||
|
prevAvgLatency = float64(prevStats.LatencySumMs) / float64(prevStats.Requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
trends = &DashboardTrends{
|
||||||
|
Requests: CalculateTrend(totalRequests, prevStats.Requests),
|
||||||
|
Tokens: CalculateTrend(ts.TokensIn+ts.TokensOut, prevStats.TokensIn+prevStats.TokensOut),
|
||||||
|
ErrorRate: CalculateTrendFloat(errorRate, prevErrorRate),
|
||||||
|
Latency: CalculateTrendFloat(ts.AvgLatency, prevAvgLatency),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, DashboardSummaryResponse{
|
c.JSON(http.StatusOK, DashboardSummaryResponse{
|
||||||
Period: rng.Period,
|
Period: rng.Period,
|
||||||
Requests: RequestStats{
|
Requests: RequestStats{
|
||||||
@@ -266,6 +353,147 @@ func (h *DashboardHandler) GetSummary(c *gin.Context) {
|
|||||||
AutoDisabled: autoDisabledProviderKeys,
|
AutoDisabled: autoDisabledProviderKeys,
|
||||||
},
|
},
|
||||||
TopModels: topModelStats,
|
TopModels: topModelStats,
|
||||||
|
Trends: trends,
|
||||||
UpdatedAt: time.Now().UTC().Unix(),
|
UpdatedAt: time.Now().UTC().Unix(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aggregatedStats holds aggregated statistics from daily_stats or log_records
|
||||||
|
type aggregatedStats struct {
|
||||||
|
Requests int64
|
||||||
|
Success int64
|
||||||
|
Failed int64
|
||||||
|
TokensIn int64
|
||||||
|
TokensOut int64
|
||||||
|
LatencySumMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// previousPeriodWindow calculates the comparison window for trend calculation
|
||||||
|
func previousPeriodWindow(period string) (start, end time.Time) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
switch period {
|
||||||
|
case "today":
|
||||||
|
// Compare to yesterday
|
||||||
|
yesterday := startOfDay.AddDate(0, 0, -1)
|
||||||
|
return yesterday, startOfDay
|
||||||
|
case "last7d":
|
||||||
|
// Compare: last 7 days vs 14-7 days ago
|
||||||
|
return now.AddDate(0, 0, -14), now.AddDate(0, 0, -7)
|
||||||
|
case "last30d":
|
||||||
|
// Compare: last 30 days vs 60-30 days ago
|
||||||
|
return now.AddDate(0, 0, -60), now.AddDate(0, 0, -30)
|
||||||
|
case "week":
|
||||||
|
// Compare to previous calendar week
|
||||||
|
weekday := int(startOfDay.Weekday())
|
||||||
|
if weekday == 0 {
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
currentWeekStart := startOfDay.AddDate(0, 0, -(weekday - 1))
|
||||||
|
prevWeekStart := currentWeekStart.AddDate(0, 0, -7)
|
||||||
|
return prevWeekStart, currentWeekStart
|
||||||
|
case "month":
|
||||||
|
// Compare to previous calendar month
|
||||||
|
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
prevMonthStart := currentMonthStart.AddDate(0, -1, 0)
|
||||||
|
return prevMonthStart, currentMonthStart
|
||||||
|
default:
|
||||||
|
return time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateFromDailyStats sums statistics from daily_stats table for the given date range
|
||||||
|
func (h *DashboardHandler) aggregateFromDailyStats(startDate, endDate string) (aggregatedStats, error) {
|
||||||
|
var stats aggregatedStats
|
||||||
|
err := h.db.Model(&model.DailyStat{}).
|
||||||
|
Select(`
|
||||||
|
COALESCE(SUM(requests), 0) as requests,
|
||||||
|
COALESCE(SUM(success), 0) as success,
|
||||||
|
COALESCE(SUM(failed), 0) as failed,
|
||||||
|
COALESCE(SUM(tokens_in), 0) as tokens_in,
|
||||||
|
COALESCE(SUM(tokens_out), 0) as tokens_out,
|
||||||
|
COALESCE(SUM(latency_sum_ms), 0) as latency_sum_ms
|
||||||
|
`).
|
||||||
|
Where("date >= ? AND date <= ?", startDate, endDate).
|
||||||
|
Scan(&stats).Error
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateFromLogRecords queries log_records directly for the given time range
|
||||||
|
func (h *DashboardHandler) aggregateFromLogRecords(start, end time.Time) (aggregatedStats, error) {
|
||||||
|
var stats struct {
|
||||||
|
Requests int64
|
||||||
|
Success int64
|
||||||
|
Failed int64
|
||||||
|
TokensIn int64
|
||||||
|
TokensOut int64
|
||||||
|
LatencySumMs int64
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.logBaseQuery().
|
||||||
|
Select(`
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(CASE WHEN status_code >= 200 AND status_code < 400 THEN 1 ELSE 0 END) as success,
|
||||||
|
SUM(CASE WHEN status_code >= 400 OR status_code = 0 THEN 1 ELSE 0 END) as failed,
|
||||||
|
COALESCE(SUM(tokens_in), 0) as tokens_in,
|
||||||
|
COALESCE(SUM(tokens_out), 0) as tokens_out,
|
||||||
|
COALESCE(SUM(latency_ms), 0) as latency_sum_ms
|
||||||
|
`).
|
||||||
|
Where("created_at >= ? AND created_at < ?", start, end).
|
||||||
|
Scan(&stats).Error
|
||||||
|
|
||||||
|
return aggregatedStats{
|
||||||
|
Requests: stats.Requests,
|
||||||
|
Success: stats.Success,
|
||||||
|
Failed: stats.Failed,
|
||||||
|
TokensIn: stats.TokensIn,
|
||||||
|
TokensOut: stats.TokensOut,
|
||||||
|
LatencySumMs: stats.LatencySumMs,
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTopModelsFromDailyStats aggregates top models from daily_stats JSON field
|
||||||
|
func (h *DashboardHandler) getTopModelsFromDailyStats(startDate, endDate string) ([]TopModelStat, error) {
|
||||||
|
var dailyStats []model.DailyStat
|
||||||
|
if err := h.db.Where("date >= ? AND date <= ?", startDate, endDate).Find(&dailyStats).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate top models from all days
|
||||||
|
modelMap := make(map[string]TopModelStat)
|
||||||
|
for _, ds := range dailyStats {
|
||||||
|
var topModels []model.TopModelStat
|
||||||
|
if err := json.Unmarshal([]byte(ds.TopModels), &topModels); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, tm := range topModels {
|
||||||
|
existing := modelMap[tm.Model]
|
||||||
|
existing.Model = tm.Model
|
||||||
|
existing.Requests += tm.Requests
|
||||||
|
existing.Tokens += tm.Tokens
|
||||||
|
modelMap[tm.Model] = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to slice and sort by requests
|
||||||
|
result := make([]TopModelStat, 0, len(modelMap))
|
||||||
|
for _, tm := range modelMap {
|
||||||
|
result = append(result, tm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple bubble sort for top 10 (small dataset)
|
||||||
|
for i := 0; i < len(result)-1; i++ {
|
||||||
|
for j := i + 1; j < len(result); j++ {
|
||||||
|
if result[j].Requests > result[i].Requests {
|
||||||
|
result[i], result[j] = result[j], result[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result) > 10 {
|
||||||
|
result = result[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|||||||
237
internal/api/dashboard_handler_test.go
Normal file
237
internal/api/dashboard_handler_test.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCalculateTrend(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current int64
|
||||||
|
previous int64
|
||||||
|
wantDelta *float64
|
||||||
|
wantDirection string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zero previous, zero current - stable",
|
||||||
|
current: 0,
|
||||||
|
previous: 0,
|
||||||
|
wantDelta: nil,
|
||||||
|
wantDirection: "stable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero previous, positive current - new",
|
||||||
|
current: 100,
|
||||||
|
previous: 0,
|
||||||
|
wantDelta: nil,
|
||||||
|
wantDirection: "new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "100% increase - up",
|
||||||
|
current: 200,
|
||||||
|
previous: 100,
|
||||||
|
wantDelta: ptr(100.0),
|
||||||
|
wantDirection: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "50% decrease - down",
|
||||||
|
current: 50,
|
||||||
|
previous: 100,
|
||||||
|
wantDelta: ptr(-50.0),
|
||||||
|
wantDirection: "down",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "0.3% increase - stable",
|
||||||
|
current: 1003,
|
||||||
|
previous: 1000,
|
||||||
|
wantDelta: ptr(0.3),
|
||||||
|
wantDirection: "stable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "0.5% increase - stable (boundary)",
|
||||||
|
current: 1005,
|
||||||
|
previous: 1000,
|
||||||
|
wantDelta: ptr(0.5),
|
||||||
|
wantDirection: "stable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "0.6% increase - up",
|
||||||
|
current: 1006,
|
||||||
|
previous: 1000,
|
||||||
|
wantDelta: ptr(0.6),
|
||||||
|
wantDirection: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "0.6% decrease - down",
|
||||||
|
current: 994,
|
||||||
|
previous: 1000,
|
||||||
|
wantDelta: ptr(-0.6),
|
||||||
|
wantDirection: "down",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no change - stable",
|
||||||
|
current: 100,
|
||||||
|
previous: 100,
|
||||||
|
wantDelta: ptr(0.0),
|
||||||
|
wantDirection: "stable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := CalculateTrend(tt.current, tt.previous)
|
||||||
|
|
||||||
|
if tt.wantDelta == nil {
|
||||||
|
if got.Delta != nil {
|
||||||
|
t.Errorf("expected nil delta, got %v", *got.Delta)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if got.Delta == nil {
|
||||||
|
t.Errorf("expected delta %v, got nil", *tt.wantDelta)
|
||||||
|
} else if *got.Delta != *tt.wantDelta {
|
||||||
|
t.Errorf("expected delta %v, got %v", *tt.wantDelta, *got.Delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Direction != tt.wantDirection {
|
||||||
|
t.Errorf("expected direction %q, got %q", tt.wantDirection, got.Direction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateTrendFloat(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current float64
|
||||||
|
previous float64
|
||||||
|
wantDelta *float64
|
||||||
|
wantDirection string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "zero previous, zero current - stable",
|
||||||
|
current: 0,
|
||||||
|
previous: 0,
|
||||||
|
wantDelta: nil,
|
||||||
|
wantDirection: "stable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero previous, positive current - new",
|
||||||
|
current: 0.5,
|
||||||
|
previous: 0,
|
||||||
|
wantDelta: nil,
|
||||||
|
wantDirection: "new",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "50% increase - up",
|
||||||
|
current: 0.15,
|
||||||
|
previous: 0.10,
|
||||||
|
wantDelta: ptr(50.0),
|
||||||
|
wantDirection: "up",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "33.3% decrease - down",
|
||||||
|
current: 0.10,
|
||||||
|
previous: 0.15,
|
||||||
|
wantDelta: ptr(-33.3),
|
||||||
|
wantDirection: "down",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := CalculateTrendFloat(tt.current, tt.previous)
|
||||||
|
|
||||||
|
if tt.wantDelta == nil {
|
||||||
|
if got.Delta != nil {
|
||||||
|
t.Errorf("expected nil delta, got %v", *got.Delta)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if got.Delta == nil {
|
||||||
|
t.Errorf("expected delta %v, got nil", *tt.wantDelta)
|
||||||
|
} else if *got.Delta != *tt.wantDelta {
|
||||||
|
t.Errorf("expected delta %v, got %v", *tt.wantDelta, *got.Delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Direction != tt.wantDirection {
|
||||||
|
t.Errorf("expected direction %q, got %q", tt.wantDirection, got.Direction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousPeriodWindow(t *testing.T) {
|
||||||
|
// These tests verify the structure of the period calculation
|
||||||
|
// Not testing exact timestamps since they depend on current time
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
period string
|
||||||
|
expectZero bool
|
||||||
|
expectWindow bool
|
||||||
|
}{
|
||||||
|
{"today", false, true},
|
||||||
|
{"last7d", false, true},
|
||||||
|
{"last30d", false, true},
|
||||||
|
{"week", false, true},
|
||||||
|
{"month", false, true},
|
||||||
|
{"all", true, false},
|
||||||
|
{"invalid", true, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.period, func(t *testing.T) {
|
||||||
|
start, end := previousPeriodWindow(tt.period)
|
||||||
|
|
||||||
|
if tt.expectZero {
|
||||||
|
if !start.IsZero() || !end.IsZero() {
|
||||||
|
t.Errorf("expected zero times for period %q", tt.period)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if start.IsZero() || end.IsZero() {
|
||||||
|
t.Errorf("expected non-zero times for period %q", tt.period)
|
||||||
|
}
|
||||||
|
if !start.Before(end) {
|
||||||
|
t.Errorf("expected start < end for period %q", tt.period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousPeriodWindowToday(t *testing.T) {
|
||||||
|
start, end := previousPeriodWindow("today")
|
||||||
|
|
||||||
|
// Previous period for "today" should be "yesterday"
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
yesterday := startOfToday.AddDate(0, 0, -1)
|
||||||
|
|
||||||
|
if !start.Equal(yesterday) {
|
||||||
|
t.Errorf("expected start to be yesterday (%v), got %v", yesterday, start)
|
||||||
|
}
|
||||||
|
if !end.Equal(startOfToday) {
|
||||||
|
t.Errorf("expected end to be start of today (%v), got %v", startOfToday, end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreviousPeriodWindowLast7d(t *testing.T) {
|
||||||
|
start, end := previousPeriodWindow("last7d")
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
expected14DaysAgo := now.AddDate(0, 0, -14)
|
||||||
|
expected7DaysAgo := now.AddDate(0, 0, -7)
|
||||||
|
|
||||||
|
// Allow 1 second tolerance for test execution time
|
||||||
|
if start.Sub(expected14DaysAgo).Abs() > time.Second {
|
||||||
|
t.Errorf("expected start ~14 days ago, got %v (expected %v)", start, expected14DaysAgo)
|
||||||
|
}
|
||||||
|
if end.Sub(expected7DaysAgo).Abs() > time.Second {
|
||||||
|
t.Errorf("expected end ~7 days ago, got %v (expected %v)", end, expected7DaysAgo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr(v float64) *float64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user