diff --git a/main.go b/main.go index 489385bf..d2e5a9ec 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,7 @@ var ( userService services.IUserService languageMappingService services.ILanguageMappingService projectLabelService services.IProjectLabelService + durationService services.IDurationService summaryService services.ISummaryService aggregationService services.IAggregationService mailService services.IMailService @@ -154,7 +155,8 @@ func main() { languageMappingService = services.NewLanguageMappingService(languageMappingRepository) projectLabelService = services.NewProjectLabelService(projectLabelRepository) 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) keyValueService = services.NewKeyValueService(keyValueRepository) reportService = services.NewReportService(summaryService, userService, mailService) diff --git a/mocks/duration_service.go b/mocks/duration_service.go new file mode 100644 index 00000000..a3406320 --- /dev/null +++ b/mocks/duration_service.go @@ -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) +} diff --git a/models/duration.go b/models/duration.go new file mode 100644 index 00000000..fbc3f8c4 --- /dev/null +++ b/models/duration.go @@ -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 +} diff --git a/models/durations.go b/models/durations.go new file mode 100644 index 00000000..81c6bbbc --- /dev/null +++ b/models/durations.go @@ -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] +} diff --git a/services/duration.go b/services/duration.go new file mode 100644 index 00000000..591ce98e --- /dev/null +++ b/services/duration.go @@ -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 +} diff --git a/services/duration_test.go b/services/duration_test.go new file mode 100644 index 00000000..3ac4c780 --- /dev/null +++ b/services/duration_test.go @@ -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 +} diff --git a/services/services.go b/services/services.go index 0dd954f3..8d80cd9b 100644 --- a/services/services.go +++ b/services/services.go @@ -74,6 +74,10 @@ type IMailService interface { SendReport(*models.User, *models.Report) error } +type IDurationService interface { + Get(time.Time, time.Time, *models.User) ([]*models.Duration, error) +} + type ISummaryService interface { Aliased(time.Time, time.Time, *models.User, SummaryRetriever, bool) (*models.Summary, error) Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error) diff --git a/services/summary.go b/services/summary.go index 2ccce4ed..5ae7136b 100644 --- a/services/summary.go +++ b/services/summary.go @@ -9,33 +9,30 @@ import ( "github.com/muety/wakapi/models" "github.com/muety/wakapi/repositories" "github.com/patrickmn/go-cache" - "math" "sort" "strings" "time" ) -const HeartbeatDiffThreshold = 2 * time.Minute - type SummaryService struct { config *config.Config cache *cache.Cache eventBus *hub.Hub repository repositories.ISummaryRepository - heartbeatService IHeartbeatService + durationService IDurationService aliasService IAliasService projectLabelService IProjectLabelService } 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{ config: config.Get(), cache: cache.New(24*time.Hour, 24*time.Hour), eventBus: config.EventBus(), repository: summaryRepo, - heartbeatService: heartbeatService, + durationService: durationService, aliasService: aliasService, projectLabelService: projectLabelService, } @@ -99,7 +96,7 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod 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) for _, interval := range missingIntervals { 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) { // Initialize and fetch data - var heartbeats models.Heartbeats - if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil { - heartbeats = rawHeartbeats + var durations models.Durations + if result, err := srv.durationService.Get(from, to, user); err == nil { + durations = result } else { return nil, err } @@ -132,10 +129,10 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo typedAggregations := make(chan models.SummaryItemContainer) defer close(typedAggregations) 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 languageItems []*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 { - from = time.Time(heartbeats.First().Time) - to = time.Time(heartbeats.Last().Time) + if durations.Len() > 0 { + from = time.Time(durations.First().Time) + to = time.Time(durations.Last().Time) } summary := &models.Summary{ @@ -195,34 +192,15 @@ func (srv *SummaryService) Insert(summary *models.Summary) error { // Private summary generation and utility methods -func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) { - durations := make(map[string]time.Duration) - - for i, h := range heartbeats { - key := h.GetKey(summaryType) - - if _, ok := durations[key]; !ok { - durations[key] = time.Duration(0) - } - - if i == 0 { - continue - } +func (srv *SummaryService) aggregateBy(durations []*models.Duration, summaryType uint8, c chan models.SummaryItemContainer) { + mapping := make(map[string]time.Duration) - 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 + for _, d := range durations { + mapping[d.GetKey(summaryType)] += d.Duration } items := make([]*models.SummaryItem, 0) - for k, v := range durations { + for k, v := range mapping { items = append(items, &models.SummaryItem{ Key: k, Total: v / time.Second, diff --git a/services/summary_test.go b/services/summary_test.go index 78800cd5..1bfefad5 100644 --- a/services/summary_test.go +++ b/services/summary_test.go @@ -13,33 +13,19 @@ import ( ) const ( - TestUserId = "muety" - TestProject1 = "test-project-1" - TestProject2 = "test-project-2" - 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 + TestProjectLabel1 = "private" + TestProjectLabel2 = "work" + TestProjectLabel3 = "non-existing" ) type SummaryServiceTestSuite struct { suite.Suite TestUser *models.User TestStartTime time.Time - TestHeartbeats []*models.Heartbeat + TestDurations []*models.Duration TestLabels []*models.ProjectLabel SummaryRepository *mocks.SummaryRepositoryMock - HeartbeatService *mocks.HeartbeatServiceMock + DurationService *mocks.DurationServiceMock AliasService *mocks.AliasServiceMock ProjectLabelService *mocks.ProjectLabelServiceMock } @@ -48,9 +34,8 @@ func (suite *SummaryServiceTestSuite) SetupSuite() { suite.TestUser = &models.User{ID: TestUserId} suite.TestStartTime = time.Unix(0, MinUnixTime1) - suite.TestHeartbeats = []*models.Heartbeat{ + suite.TestDurations = []*models.Duration{ { - ID: rand.Uint64(), UserID: TestUserId, Project: TestProject1, Language: TestLanguageGo, @@ -58,19 +43,19 @@ func (suite *SummaryServiceTestSuite) SetupSuite() { OperatingSystem: TestOsLinux, Machine: TestMachine1, Time: models.CustomTime(suite.TestStartTime), + Duration: 150 * time.Second, }, { - ID: rand.Uint64(), UserID: TestUserId, Project: TestProject1, Language: TestLanguageGo, Editor: TestEditorGoland, OperatingSystem: TestOsLinux, 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, Project: TestProject1, Language: TestLanguageGo, @@ -78,6 +63,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() { OperatingSystem: TestOsLinux, Machine: TestMachine1, Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)), + Duration: 15 * time.Second, }, } suite.TestLabels = []*models.ProjectLabel{ @@ -98,7 +84,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() { func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) { suite.SummaryRepository = new(mocks.SummaryRepositoryMock) - suite.HeartbeatService = new(mocks.HeartbeatServiceMock) + suite.DurationService = new(mocks.DurationServiceMock) suite.AliasService = new(mocks.AliasServiceMock) suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock) } @@ -108,7 +94,7 @@ func TestSummaryServiceTestSuite(t *testing.T) { } 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 ( from time.Time @@ -119,7 +105,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { /* 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(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) @@ -132,36 +118,36 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() { /* 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(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) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) - assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T()) - assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T()) - assert.Zero(suite.T(), result.TotalTime()) + assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T()) + assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.ToTime.T()) + assert.Equal(suite.T(), 150*time.Second, result.TotalTime()) assertNumAllItems(suite.T(), 1, result, "") /* TEST 3 */ 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) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) - assert.Equal(suite.T(), suite.TestHeartbeats[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(), 150*time.Second, result.TotalTime()) - assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland)) - assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode)) + assert.Equal(suite.T(), suite.TestDurations[0].Time.T(), result.FromTime.T()) + assert.Equal(suite.T(), suite.TestDurations[len(suite.TestDurations)-1].Time.T(), result.ToTime.T()) + assert.Equal(suite.T(), 185*time.Second, result.TotalTime()) + assert.Equal(suite.T(), 170*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland)) + assert.Equal(suite.T(), 15*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode)) assert.Len(suite.T(), result.Editors, 2) assertNumAllItems(suite.T(), 1, result, "e") } 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 ( summaries []*models.Summary @@ -194,8 +180,8 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { } 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.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil) + suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Duration{}, nil) + suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Duration{}, nil) result, err = sut.Retrieve(from, to, suite.TestUser) @@ -203,7 +189,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { assert.NotNil(suite.T(), result) assert.Len(suite.T(), result.Projects, 1) 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 */ 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.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) assert.Nil(suite.T(), err) assert.NotNil(suite.T(), result) assert.Len(suite.T(), result.Projects, 2) - assert.Equal(suite.T(), 150*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+90*time.Minute, result.TotalTime()) + 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)) - suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2+1) + suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1) /* 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 @@ -298,7 +284,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { } 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) @@ -308,11 +294,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() { 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, 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() { - 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) @@ -347,8 +333,8 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma 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.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil) - suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil) + suite.DurationService.On("Get", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Duration{}, nil) + suite.DurationService.On("Get", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Duration{}, nil) result, err = sut.Retrieve(from, to, suite.TestUser) @@ -356,11 +342,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma assert.NotNil(suite.T(), result) assert.Len(suite.T(), result.Projects, 1) 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() { - 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) @@ -373,19 +359,19 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() { from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) - heartbeats := filter(from, to, suite.TestHeartbeats) - heartbeats = append(heartbeats, &models.Heartbeat{ - ID: rand.Uint64(), + durations := filterDurations(from, to, suite.TestDurations) + durations = append(durations, &models.Duration{ UserID: TestUserId, Project: TestProject2, Language: TestLanguageGo, Editor: TestEditorGoland, OperatingSystem: TestOsLinux, 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("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).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() { - sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService) + sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService) var ( from time.Time @@ -412,20 +398,20 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour) - heartbeats := filter(from, to, suite.TestHeartbeats) - heartbeats = append(heartbeats, &models.Heartbeat{ - ID: rand.Uint64(), + durations := filterDurations(from, to, suite.TestDurations) + durations = append(durations, &models.Duration{ UserID: TestUserId, Project: TestProject2, Language: TestLanguageGo, Editor: TestEditorGoland, OperatingSystem: TestOsLinux, 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.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("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).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.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 { - 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) +func filterDurations(from, to time.Time, durations []*models.Duration) []*models.Duration { + filtered := make([]*models.Duration, 0, len(durations)) + for _, d := range durations { + if (d.Time.T().Equal(from) || d.Time.T().After(from)) && d.Time.T().Before(to) { + filtered = append(filtered, d) } } return filtered diff --git a/version.txt b/version.txt index 593d7210..69d74092 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.30.4 +2.0.0-SNAPSHOT-01 \ No newline at end of file