1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

refactor: introduce concept of durations (resolve #261)

This commit is contained in:
Ferdinand Mütsch 2021-12-14 15:30:03 +01:00
parent 15c8838fea
commit c8a07cee36
10 changed files with 443 additions and 110 deletions

View File

@ -64,6 +64,7 @@ var (
userService services.IUserService userService services.IUserService
languageMappingService services.ILanguageMappingService languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService projectLabelService services.IProjectLabelService
durationService services.IDurationService
summaryService services.ISummaryService summaryService services.ISummaryService
aggregationService services.IAggregationService aggregationService services.IAggregationService
mailService services.IMailService mailService services.IMailService
@ -154,7 +155,8 @@ func main() {
languageMappingService = services.NewLanguageMappingService(languageMappingRepository) languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository) projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService) heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService) durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService) aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository) keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService) reportService = services.NewReportService(summaryService, userService, mailService)

16
mocks/duration_service.go Normal file
View File

@ -0,0 +1,16 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
"time"
)
type DurationServiceMock struct {
mock.Mock
}
func (m *DurationServiceMock) Get(time time.Time, time2 time.Time, user *models.User) ([]*models.Duration, error) {
args := m.Called(time, time2, user)
return args.Get(0).([]*models.Duration), args.Error(1)
}

64
models/duration.go Normal file
View File

@ -0,0 +1,64 @@
package models
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/mitchellh/hashstructure/v2"
"time"
)
type Duration struct {
UserID string `json:"user_id"`
Time CustomTime `json:"time" hash:"ignore"`
Duration time.Duration `json:"duration" hash:"ignore"`
Project string `json:"project"`
Language string `json:"language"`
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
GroupHash string `json:"-" hash:"ignore"`
}
func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
d := &Duration{
UserID: h.UserID,
Time: h.Time,
Duration: 0,
Project: h.Project,
Language: h.Language,
Editor: h.Editor,
OperatingSystem: h.OperatingSystem,
Machine: h.Machine,
}
return d.Hashed()
}
func (d *Duration) Hashed() *Duration {
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err)
}
d.GroupHash = fmt.Sprintf("%x", hash)
return d
}
func (d *Duration) GetKey(t uint8) (key string) {
switch t {
case SummaryProject:
key = d.Project
case SummaryEditor:
key = d.Editor
case SummaryLanguage:
key = d.Language
case SummaryOS:
key = d.OperatingSystem
case SummaryMachine:
key = d.Machine
}
if key == "" {
key = UnknownSummaryKey
}
return key
}

38
models/durations.go Normal file
View File

@ -0,0 +1,38 @@
package models
import "sort"
type Durations []*Duration
func (d Durations) Len() int {
return len(d)
}
func (d Durations) Less(i, j int) bool {
return d[i].Time.T().Before(d[j].Time.T())
}
func (d Durations) Swap(i, j int) {
d[i], d[j] = d[j], d[i]
}
func (d *Durations) Sorted() *Durations {
sort.Sort(d)
return d
}
func (d *Durations) First() *Duration {
// assumes slice to be sorted
if d.Len() == 0 {
return nil
}
return (*d)[0]
}
func (d *Durations) Last() *Duration {
// assumes slice to be sorted
if d.Len() == 0 {
return nil
}
return (*d)[d.Len()-1]
}

77
services/duration.go Normal file
View File

@ -0,0 +1,77 @@
package services
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"time"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type DurationService struct {
config *config.Config
heartbeatService IHeartbeatService
}
func NewDurationService(heartbeatService IHeartbeatService) *DurationService {
srv := &DurationService{
config: config.Get(),
heartbeatService: heartbeatService,
}
return srv
}
func (srv *DurationService) Get(from, to time.Time, user *models.User) ([]*models.Duration, error) {
heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user)
if err != nil {
return nil, err
}
// Aggregation
var count int
var latest *models.Duration
mapping := make(map[string][]*models.Duration)
for _, h := range heartbeats {
d1 := models.NewDurationFromHeartbeat(h)
if list, ok := mapping[d1.GroupHash]; !ok || len(list) < 1 {
mapping[d1.GroupHash] = []*models.Duration{d1}
}
if latest == nil {
latest = d1
continue
}
dur := d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))
if dur > HeartbeatDiffThreshold {
dur = HeartbeatDiffThreshold
}
latest.Duration += dur
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash {
list := mapping[d1.GroupHash]
if d0 := list[len(list)-1]; d0 != d1 {
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1)
}
latest = d1
}
count++
}
durations := make([]*models.Duration, 0, count)
for _, list := range mapping {
for _, d := range list {
if d.Duration == 0 {
d.Duration = HeartbeatDiffThreshold
}
durations = append(durations, d)
}
}
return durations, nil
}

168
services/duration_test.go Normal file
View File

@ -0,0 +1,168 @@
package services
import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"math/rand"
"testing"
"time"
)
const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
TestEditorGoland = "GoLand"
TestEditorIntellij = "idea"
TestEditorVscode = "vscode"
TestOsLinux = "Linux"
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
MinUnixTime1 = 1601510400000 * 1e6
)
type DurationServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
TestLabels []*models.ProjectLabel
HeartbeatService *mocks.HeartbeatServiceMock
}
func (suite *DurationServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId}
suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime), // 0:00
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)), // 2:40
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), // 3:00
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3*time.Minute + 10*time.Second)), // 3:10
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject1,
Language: TestLanguageGo,
Editor: TestEditorVscode,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3*time.Minute + 15*time.Second)), // 3:15
},
}
}
func (suite *DurationServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
}
func TestDurationServiceTestSuite(t *testing.T) {
suite.Run(t, new(DurationServiceTestSuite))
}
func (suite *DurationServiceTestSuite) TestDurationService_Get() {
sut := NewDurationService(suite.HeartbeatService)
var (
from time.Time
to time.Time
durations models.Durations
err error
)
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.Empty(suite.T(), durations)
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.Len(suite.T(), durations, 1)
assert.Equal(suite.T(), HeartbeatDiffThreshold, durations.First().Duration)
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser)
assert.Nil(suite.T(), err)
assert.Len(suite.T(), durations, 3)
assert.Equal(suite.T(), 150*time.Second, durations[0].Duration)
assert.Equal(suite.T(), 20*time.Second, durations[1].Duration)
assert.Equal(suite.T(), 15*time.Second, durations[2].Duration)
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
}
func filterHeartbeats(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
filtered = append(filtered, h)
}
}
return filtered
}

View File

@ -74,6 +74,10 @@ type IMailService interface {
SendReport(*models.User, *models.Report) error SendReport(*models.User, *models.Report) error
} }
type IDurationService interface {
Get(time.Time, time.Time, *models.User) ([]*models.Duration, error)
}
type ISummaryService interface { type ISummaryService interface {
Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error) Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error)
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error) Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)

View File

@ -9,33 +9,30 @@ import (
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories" "github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
"math"
"sort" "sort"
"strings" "strings"
"time" "time"
) )
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct { type SummaryService struct {
config *config.Config config *config.Config
cache *cache.Cache cache *cache.Cache
eventBus *hub.Hub eventBus *hub.Hub
repository repositories.ISummaryRepository repository repositories.ISummaryRepository
heartbeatService IHeartbeatService durationService IDurationService
aliasService IAliasService aliasService IAliasService
projectLabelService IProjectLabelService projectLabelService IProjectLabelService
} }
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error) type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService { func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
srv := &SummaryService{ srv := &SummaryService{
config: config.Get(), config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour), cache: cache.New(24*time.Hour, 24*time.Hour),
eventBus: config.EventBus(), eventBus: config.EventBus(),
repository: summaryRepo, repository: summaryRepo,
heartbeatService: heartbeatService, durationService: durationService,
aliasService: aliasService, aliasService: aliasService,
projectLabelService: projectLabelService, projectLabelService: projectLabelService,
} }
@ -99,7 +96,7 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
return nil, err return nil, err
} }
// Generate missing slots (especially before and after existing summaries) from raw heartbeats // Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats)
missingIntervals := srv.getMissingIntervals(from, to, summaries) missingIntervals := srv.getMissingIntervals(from, to, summaries)
for _, interval := range missingIntervals { for _, interval := range missingIntervals {
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil { if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
@ -120,9 +117,9 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) { func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
// Initialize and fetch data // Initialize and fetch data
var heartbeats models.Heartbeats var durations models.Durations
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil { if result, err := srv.durationService.Get(from, to, user); err == nil {
heartbeats = rawHeartbeats durations = result
} else { } else {
return nil, err return nil, err
} }
@ -132,10 +129,10 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
typedAggregations := make(chan models.SummaryItemContainer) typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations) defer close(typedAggregations)
for _, t := range types { for _, t := range types {
go srv.aggregateBy(heartbeats, t, typedAggregations) go srv.aggregateBy(durations, t, typedAggregations)
} }
// Aggregate raw heartbeats by types in parallel and collect them // Aggregate durations (formerly raw heartbeats) by types in parallel and collect them
var projectItems []*models.SummaryItem var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem var languageItems []*models.SummaryItem
var editorItems []*models.SummaryItem var editorItems []*models.SummaryItem
@ -158,9 +155,9 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
} }
} }
if heartbeats.Len() > 0 { if durations.Len() > 0 {
from = time.Time(heartbeats.First().Time) from = time.Time(durations.First().Time)
to = time.Time(heartbeats.Last().Time) to = time.Time(durations.Last().Time)
} }
summary := &models.Summary{ summary := &models.Summary{
@ -195,34 +192,15 @@ func (srv *SummaryService) Insert(summary *models.Summary) error {
// Private summary generation and utility methods // Private summary generation and utility methods
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) { func (srv *SummaryService) aggregateBy(durations []*models.Duration, summaryType uint8, c chan models.SummaryItemContainer) {
durations := make(map[string]time.Duration) mapping := make(map[string]time.Duration)
for i, h := range heartbeats { for _, d := range durations {
key := h.GetKey(summaryType) mapping[d.GetKey(summaryType)] += d.Duration
if _, ok := durations[key]; !ok {
durations[key] = time.Duration(0)
}
if i == 0 {
continue
}
t1, t2, tdiff := h.Time.T(), heartbeats[i-1].Time.T(), time.Duration(0)
// This is a hack. The time difference between two heartbeats from two subsequent day (e.g. 23:59:59 and 00:00:01) are ignored.
// This is to prevent a discrepancy between summaries computed solely from heartbeats and summaries involving pre-aggregated per-day summaries.
// For the latter, a duration is already pre-computed and information about individual heartbeats is lost, so there can be no cross-day overflow.
// Essentially, we simply ignore such edge-case heartbeats here, which makes the eventual total duration potentially a bit shorter.
if t1.Day() == t2.Day() {
timePassed := t1.Sub(t2)
tdiff = time.Duration(int64(math.Min(float64(timePassed), float64(HeartbeatDiffThreshold))))
}
durations[key] += tdiff
} }
items := make([]*models.SummaryItem, 0) items := make([]*models.SummaryItem, 0)
for k, v := range durations { for k, v := range mapping {
items = append(items, &models.SummaryItem{ items = append(items, &models.SummaryItem{
Key: k, Key: k,
Total: v / time.Second, Total: v / time.Second,

View File

@ -13,33 +13,19 @@ import (
) )
const ( const (
TestUserId = "muety" TestProjectLabel1 = "private"
TestProject1 = "test-project-1" TestProjectLabel2 = "work"
TestProject2 = "test-project-2" TestProjectLabel3 = "non-existing"
TestProjectLabel1 = "private"
TestProjectLabel2 = "work"
TestProjectLabel3 = "non-existing"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
TestEditorGoland = "GoLand"
TestEditorIntellij = "idea"
TestEditorVscode = "vscode"
TestOsLinux = "Linux"
TestOsWin = "Windows"
TestMachine1 = "muety-desktop"
TestMachine2 = "muety-work"
MinUnixTime1 = 1601510400000 * 1e6
) )
type SummaryServiceTestSuite struct { type SummaryServiceTestSuite struct {
suite.Suite suite.Suite
TestUser *models.User TestUser *models.User
TestStartTime time.Time TestStartTime time.Time
TestHeartbeats []*models.Heartbeat TestDurations []*models.Duration
TestLabels []*models.ProjectLabel TestLabels []*models.ProjectLabel
SummaryRepository *mocks.SummaryRepositoryMock SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock DurationService *mocks.DurationServiceMock
AliasService *mocks.AliasServiceMock AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock ProjectLabelService *mocks.ProjectLabelServiceMock
} }
@ -48,9 +34,8 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
suite.TestUser = &models.User{ID: TestUserId} suite.TestUser = &models.User{ID: TestUserId}
suite.TestStartTime = time.Unix(0, MinUnixTime1) suite.TestStartTime = time.Unix(0, MinUnixTime1)
suite.TestHeartbeats = []*models.Heartbeat{ suite.TestDurations = []*models.Duration{
{ {
ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
Project: TestProject1, Project: TestProject1,
Language: TestLanguageGo, Language: TestLanguageGo,
@ -58,19 +43,19 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
OperatingSystem: TestOsLinux, OperatingSystem: TestOsLinux,
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime), Time: models.CustomTime(suite.TestStartTime),
Duration: 150 * time.Second,
}, },
{ {
ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
Project: TestProject1, Project: TestProject1,
Language: TestLanguageGo, Language: TestLanguageGo,
Editor: TestEditorGoland, Editor: TestEditorGoland,
OperatingSystem: TestOsLinux, OperatingSystem: TestOsLinux,
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), Time: models.CustomTime(suite.TestStartTime.Add((30 + 130) * time.Second)),
Duration: 20 * time.Second,
}, },
{ {
ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
Project: TestProject1, Project: TestProject1,
Language: TestLanguageGo, Language: TestLanguageGo,
@ -78,6 +63,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
OperatingSystem: TestOsLinux, OperatingSystem: TestOsLinux,
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
Duration: 15 * time.Second,
}, },
} }
suite.TestLabels = []*models.ProjectLabel{ suite.TestLabels = []*models.ProjectLabel{
@ -98,7 +84,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) { func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock) suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock) suite.DurationService = new(mocks.DurationServiceMock)
suite.AliasService = new(mocks.AliasServiceMock) suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock) suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
} }
@ -108,7 +94,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var ( var (
from time.Time from time.Time
@ -119,7 +105,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */ /* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
@ -132,36 +118,36 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */ /* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second) from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err) assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T()) assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T()) assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.ToTime.T())
assert.Zero(suite.T(), result.TotalTime()) assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assertNumAllItems(suite.T(), 1, result, "") assertNumAllItems(suite.T(), 1, result, "")
/* TEST 3 */ /* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil) suite.DurationService.On("Get", from, to, suite.TestUser).Return(filterDurations(from, to, suite.TestDurations), nil)
result, err = sut.Summarize(from, to, suite.TestUser) result, err = sut.Summarize(from, to, suite.TestUser)
assert.Nil(suite.T(), err) assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T()) assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T())
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T()) assert.Equal(suite.T(), suite.TestDurations[len(suite.TestDurations)-1].Time.T(), result.ToTime.T())
assert.Equal(suite.T(), 150*time.Second, result.TotalTime()) assert.Equal(suite.T(), 185*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland)) assert.Equal(suite.T(), 170*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode)) assert.Equal(suite.T(), 15*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Len(suite.T(), result.Editors, 2) assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e") assertNumAllItems(suite.T(), 1, result, "e")
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var ( var (
summaries []*models.Summary summaries []*models.Summary
@ -194,8 +180,8 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
} }
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil) suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Duration{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil) suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Duration{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser) result, err = sut.Retrieve(from, to, suite.TestUser)
@ -203,7 +189,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1) assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime()) assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2) suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2)
/* TEST 2 */ /* TEST 2 */
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour) from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
@ -245,17 +231,17 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
} }
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil) suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return(filterDurations(from, summaries[0].FromTime.T(), suite.TestDurations), nil)
result, err = sut.Retrieve(from, to, suite.TestUser) result, err = sut.Retrieve(from, to, suite.TestUser)
assert.Nil(suite.T(), err) assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 2) assert.Len(suite.T(), result.Projects, 2)
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime()) assert.Equal(suite.T(), 185*time.Second+90*time.Minute, result.TotalTime())
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1)) assert.Equal(suite.T(), 185*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2)) assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1) suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1)
/* TEST 3 */ /* TEST 3 */
from = time.Date(suite.TestStartTime.Year(), suite.TestStartTime.Month(), suite.TestStartTime.Day()+1, 0, 0, 0, 0, suite.TestStartTime.Location()) // start of next day from = time.Date(suite.TestStartTime.Year(), suite.TestStartTime.Month(), suite.TestStartTime.Day()+1, 0, 0, 0, 0, suite.TestStartTime.Location()) // start of next day
@ -298,7 +284,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
} }
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser).Return(filter(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestHeartbeats), nil) suite.DurationService.On("Get", summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestUser).Return(filterDurations(summaries[0].ToTime.T(), summaries[1].FromTime.T(), suite.TestDurations), nil)
result, err = sut.Retrieve(from, to, suite.TestUser) result, err = sut.Retrieve(from, to, suite.TestUser)
@ -308,11 +294,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
assert.Equal(suite.T(), 90*time.Minute, result.TotalTime()) assert.Equal(suite.T(), 90*time.Minute, result.TotalTime())
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1)) assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2)) assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1+1) suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1+1)
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() { func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil) suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
@ -347,8 +333,8 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice! summaries = append(summaries, &(*summaries[0])) // add same summary again -> mustn't be counted twice!
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil) suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil) suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Duration{}, nil)
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil) suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Duration{}, nil)
result, err = sut.Retrieve(from, to, suite.TestUser) result, err = sut.Retrieve(from, to, suite.TestUser)
@ -356,11 +342,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Len(suite.T(), result.Projects, 1) assert.Len(suite.T(), result.Projects, 1)
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime()) assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2) suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2)
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil) suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
@ -373,19 +359,19 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
heartbeats := filter(from, to, suite.TestHeartbeats) durations := filterDurations(from, to, suite.TestDurations)
heartbeats = append(heartbeats, &models.Heartbeat{ durations = append(durations, &models.Duration{
ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
Project: TestProject2, Project: TestProject2,
Language: TestLanguageGo, Language: TestLanguageGo,
Editor: TestEditorGoland, Editor: TestEditorGoland,
OperatingSystem: TestOsLinux, OperatingSystem: TestOsLinux,
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(heartbeats[len(heartbeats)-1].Time.T().Add(10 * time.Second)), Time: models.CustomTime(durations[len(durations)-1].Time.T().Add(10 * time.Second)),
Duration: 0, // not relevant here
}) })
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(heartbeats, nil) suite.DurationService.On("Get", from, to, suite.TestUser).Return(durations, nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil) suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject2, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject2, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject2, nil)
@ -401,7 +387,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
} }
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() { func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var ( var (
from time.Time from time.Time
@ -412,20 +398,20 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
heartbeats := filter(from, to, suite.TestHeartbeats) durations := filterDurations(from, to, suite.TestDurations)
heartbeats = append(heartbeats, &models.Heartbeat{ durations = append(durations, &models.Duration{
ID: rand.Uint64(),
UserID: TestUserId, UserID: TestUserId,
Project: TestProject2, Project: TestProject2,
Language: TestLanguageGo, Language: TestLanguageGo,
Editor: TestEditorGoland, Editor: TestEditorGoland,
OperatingSystem: TestOsLinux, OperatingSystem: TestOsLinux,
Machine: TestMachine1, Machine: TestMachine1,
Time: models.CustomTime(heartbeats[len(heartbeats)-1].Time.T().Add(10 * time.Second)), Time: models.CustomTime(durations[len(durations)-1].Time.T().Add(10 * time.Second)),
Duration: 10 * time.Second,
}) })
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once() suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(heartbeats, nil) suite.DurationService.On("Get", from, to, suite.TestUser).Return(durations, nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil) suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject1, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject1, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject1, nil) suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject1, nil)
@ -435,14 +421,14 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
assert.Nil(suite.T(), err) assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result) assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), 160*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1)) assert.Equal(suite.T(), 195*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
} }
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat { func filterDurations(from, to time.Time, durations []*models.Duration) []*models.Duration {
filtered := make([]*models.Heartbeat, 0, len(heartbeats)) filtered := make([]*models.Duration, 0, len(durations))
for _, h := range heartbeats { for _, d := range durations {
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) { if (d.Time.T().Equal(from) || d.Time.T().After(from)) && d.Time.T().Before(to) {
filtered = append(filtered, h) filtered = append(filtered, d)
} }
} }
return filtered return filtered

View File

@ -1 +1 @@
1.30.4 2.0.0-SNAPSHOT-01