diff --git a/backend/graph/schema.resolvers.go b/backend/graph/schema.resolvers.go index acfd1ca..a82d643 100644 --- a/backend/graph/schema.resolvers.go +++ b/backend/graph/schema.resolvers.go @@ -304,7 +304,7 @@ func (r *mutationResolver) UpsertDictionary(ctx context.Context, input model.Ups // HandleSwipe is the resolver for the handleSwipe field. func (r *mutationResolver) HandleSwipe(ctx context.Context, input model.NewSwipeRecord) ([]*model.Card, error) { - panic(fmt.Errorf("not implemented: HandleSwipe - handleSwipe")) + return r.U.HandleSwipe(ctx, input) } // Card is the resolver for the card field. diff --git a/backend/graph/services/card.go b/backend/graph/services/card.go index 48fd354..85ef6a2 100644 --- a/backend/graph/services/card.go +++ b/backend/graph/services/card.go @@ -31,9 +31,14 @@ type CardService interface { GetCardsByIDs(ctx context.Context, ids []int64) ([]*model.Card, error) FetchAllCardsByCardGroup(ctx context.Context, cardGroupID int64, first *int) ([]*model.Card, error) AddNewCards(ctx context.Context, targetCards []model.Card, cardGroupID int64) ([]*model.Card, error) - GetCardsByUserAndCardGroup(ctx context.Context, cardGroupID int64, order string, limit int) ([]repository.Card, error) - GetRandomCardsFromRecentUpdates(ctx context.Context, cardGroupID int64, limit int, updatedSortOrder string, intervalDaysSortOrder string) ([]model.Card, error) - GetCardsByDefaultLogic(ctx context.Context, cardGroupID int64, limit int) ([]repository.Card, error) + GetCardsByUserAndCardGroup(ctx context.Context, cardGroupID int64, + order string, limit int) ([]*repository.Card, error) + ShuffleCards(cards []repository.Card, limit int) []*model.Card + GetRandomCardsFromRecentUpdates(ctx context.Context, cardGroupID int64, + limit int, updatedSortOrder string, intervalDaysSortOrder string) ([]*model.Card, error) + GetCardsByDefaultLogic(ctx context.Context, cardGroupID int64, + limit int) ([]*repository.Card, error) + GetRandomRecentCards(ctx context.Context, fromDate time.Time, limit int, sortOrder string) ([]*model.Card, error) } func NewCardService(db *gorm.DB, defaultLimit int) CardService { @@ -70,11 +75,11 @@ func ConvertToCard(card repository.Card) *model.Card { } } -func ConvertToCards(cards []repository.Card) []model.Card { - var result []model.Card +func ConvertToCards(cards []repository.Card) []*model.Card { + var result []*model.Card for _, card := range cards { convertedCard := ConvertToCard(card) - result = append(result, *convertedCard) + result = append(result, convertedCard) } return result } @@ -318,8 +323,9 @@ func (s *cardService) AddNewCards(ctx context.Context, targetCards []model.Card, } func (s *cardService) GetCardsByUserAndCardGroup( - ctx context.Context, cardGroupID int64, order string, limit int) ([]repository.Card, error) { - var cards []repository.Card + ctx context.Context, cardGroupID int64, order string, + limit int) ([]*repository.Card, error) { + var cards []*repository.Card // Query to find the latest cards with matching user_id and cardgroup_id err := s.db.WithContext(ctx). @@ -335,7 +341,22 @@ func (s *cardService) GetCardsByUserAndCardGroup( return cards, nil } -func (s *cardService) GetRandomCardsFromRecentUpdates(ctx context.Context, cardGroupID int64, limit int, updatedSortOrder string, intervalDaysSortOrder string) ([]model.Card, error) { +// Shuffle cards +func (s *cardService) ShuffleCards(cards []repository.Card, + limit int) []*model.Card { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(cards), func(i, j int) { cards[i], cards[j] = cards[j], cards[i] }) + + if len(cards) <= limit { + return ConvertToCards(cards) + } + + selectedCards := cards[:limit] + return ConvertToCards(selectedCards) +} + +func (s *cardService) GetRandomCardsFromRecentUpdates(ctx context.Context, + cardGroupID int64, limit int, updatedSortOrder string, intervalDaysSortOrder string) ([]*model.Card, error) { var cards []repository.Card // Validate sortOrder for updated and intervalDays @@ -358,21 +379,13 @@ func (s *cardService) GetRandomCardsFromRecentUpdates(ctx context.Context, cardG return nil, goerr.Wrap(err, "Failed to retrieve recent cards") } - // Shuffle the cards if necessary - rng := rand.New(rand.NewSource(time.Now().UnixNano())) - rng.Shuffle(len(cards), func(i, j int) { cards[i], cards[j] = cards[j], cards[i] }) - - if len(cards) <= limit { - return ConvertToCards(cards), nil - } - - randomCards := cards[:limit] - - return ConvertToCards(randomCards), nil + // Shuffle the cards + return s.ShuffleCards(cards, limit), nil } -func (s *cardService) GetCardsByDefaultLogic(ctx context.Context, cardGroupID int64, limit int) ([]repository.Card, error) { - var cards []repository.Card +func (s *cardService) GetCardsByDefaultLogic(ctx context.Context, + cardGroupID int64, limit int) ([]*repository.Card, error) { + var cards []*repository.Card err := s.db.WithContext(ctx). Where("cardgroup_id = ?", cardGroupID). @@ -387,3 +400,27 @@ func (s *cardService) GetCardsByDefaultLogic(ctx context.Context, cardGroupID in return cards, nil } + +func (s *cardService) GetRandomRecentCards( + ctx context.Context, fromDate time.Time, limit int, sortOrder string) ([]*model.Card, error) { + var cards []repository.Card + + // Validate sortOrder, default to "desc" if not valid + if sortOrder != repo.ASC && sortOrder != repo.DESC { + sortOrder = repo.DESC + } + + // Create the query with the fromDate filter, ordering by created, and limit + err := s.db.WithContext(ctx). + Where("created >= ?", fromDate). + Order(fmt.Sprintf("created %s", sortOrder)). + Limit(limit). + Find(&cards).Error + + if err != nil { + return nil, goerr.Wrap(err, "Failed to retrieve recent cards") + } + + // Shuffle the cards + return s.ShuffleCards(cards, limit), nil +} diff --git a/backend/graph/services/card_test.go b/backend/graph/services/card_test.go index fd338fe..62d5f54 100644 --- a/backend/graph/services/card_test.go +++ b/backend/graph/services/card_test.go @@ -607,6 +607,156 @@ func (suite *CardTestSuite) TestCardService() { } }) + suite.Run("Normal_ShuffleCards", func() { + // Arrange + createdGroup, _, _ := testutils.CreateUserAndCardGroup(ctx, userService, cardGroupService, roleService) + + // Create 10 dummy cards and associate them with the created card group + limit := 10 + cards := []repository.Card{} + for i := 0; i < limit; i++ { + card := repository.Card{ + Front: "Front " + strconv.Itoa(i), + Back: "Back " + strconv.Itoa(i), + ReviewDate: time.Now().UTC(), + IntervalDays: 1, + Created: time.Now().UTC(), + Updated: time.Now().UTC(), + CardGroupID: createdGroup.ID, + CardGroup: repository.Cardgroup{ + ID: createdGroup.ID, + }, + } + cards = append(cards, card) + } + + // Act + shuffledCards := cardService.ShuffleCards(cards, limit) + + // Assert + assert.Len(suite.T(), shuffledCards, len(cards)) + assert.NotEqual(suite.T(), cards, shuffledCards, "Shuffled cards should not be in the same order as the original") + + // Check if all original cards are still present after shuffle + cardMap := make(map[int64]*model.Card) + for _, card := range shuffledCards { + cardMap[card.ID] = card + } + for _, card := range cards { + _, exists := cardMap[card.ID] + assert.True(suite.T(), exists, "All original cards should be present after shuffle") + } + }) + + suite.Run("Normal_ShuffleCards_Randomness", func() { + // Arrange + createdGroup, _, _ := testutils.CreateUserAndCardGroup(ctx, userService, cardGroupService, roleService) + + // Create 10 dummy cards and associate them with the created card group + limit := 10 + cards := []repository.Card{} + for i := 0; i < limit; i++ { + card := repository.Card{ + Front: "Front " + strconv.Itoa(i), + Back: "Back " + strconv.Itoa(i), + ReviewDate: time.Now().UTC(), + IntervalDays: 1, + Created: time.Now().UTC(), + Updated: time.Now().UTC(), + CardGroupID: createdGroup.ID, + CardGroup: repository.Cardgroup{ + ID: createdGroup.ID, + }, + } + cards = append(cards, card) + } + + // Shuffle twice + shuffledCards1 := cardService.ShuffleCards(cards, limit) + shuffledCards2 := cardService.ShuffleCards(cards, limit) + + // Assert that the order is different in at least one shuffle + assert.NotEqual(suite.T(), shuffledCards1, shuffledCards2, "Different shuffles should result in different orders") + }) + + suite.Run("Normal_GetRecentCards", func() { + // Arrange + cardService := suite.sv.(services.CardService) + userService := suite.sv.(services.UserService) + cardGroupService := suite.sv.(services.CardGroupService) + roleService := suite.sv.(services.RoleService) + ctx := context.Background() + + // Create a user and card group using testutils + createdGroup, _, _ := testutils.CreateUserAndCardGroup(ctx, userService, cardGroupService, roleService) + + // Add some cards with different creation times + for i := 0; i < 10; i++ { + input := model.NewCard{ + Front: "Recent Front " + strconv.Itoa(i), + Back: "Recent Back " + strconv.Itoa(i), + ReviewDate: time.Now().UTC().Add(-time.Duration(i) * time.Hour), + CardgroupID: createdGroup.ID, + } + _, err := cardService.CreateCard(ctx, input) + assert.NoError(suite.T(), err) + } + + // Define the date from which to fetch recent cards + fromDate := time.Now().UTC().Add(-5 * time.Hour) // fetch cards created in the last 5 hours + limit := 5 + + // Act + recentCards, err := cardService.GetRandomRecentCards(ctx, fromDate, limit, + repo.DESC) + + // Assert + assert.NoError(suite.T(), err) + assert.Len(suite.T(), recentCards, limit) + for _, card := range recentCards { + assert.True(suite.T(), card.Created.After(fromDate), "Card should be created after the fromDate") + } + }) + + suite.Run("Normal_GetRecentCards_LimitExceeds", func() { + // Arrange + cardService := suite.sv.(services.CardService) + userService := suite.sv.(services.UserService) + cardGroupService := suite.sv.(services.CardGroupService) + roleService := suite.sv.(services.RoleService) + ctx := context.Background() + + // Reuse the same method to create a user and card group + createdGroup, _, _ := testutils.CreateUserAndCardGroup(ctx, userService, cardGroupService, roleService) + + // Add some cards with different creation times + for i := 0; i < 10; i++ { + input := model.NewCard{ + Front: "Recent Front " + strconv.Itoa(i), + Back: "Recent Back " + strconv.Itoa(i), + ReviewDate: time.Now().UTC().Add(-time.Duration(i) * time.Hour), + CardgroupID: createdGroup.ID, + } + _, err := cardService.CreateCard(ctx, input) + assert.NoError(suite.T(), err) + } + + // Define the date from which to fetch recent cards + fromDate := time.Now().UTC().Add(-24 * time.Hour) + limit := 15 // Exceeds the number of available cards + + // Act + recentCards, err := cardService.GetRandomRecentCards(ctx, fromDate, limit, + repo.DESC) + + // Assert + assert.NoError(suite.T(), err) + assert.True(suite.T(), len(recentCards) <= limit, "Number of cards should not exceed the limit") + for _, card := range recentCards { + assert.True(suite.T(), card.Created.After(fromDate), "Card should be created after the fromDate") + } + }) + } func TestCardTestSuite(t *testing.T) { diff --git a/backend/pkg/usecases/dictionary_manager_usecase.go b/backend/pkg/usecases/dictionary_manager/dictionary_manager_usecase.go similarity index 97% rename from backend/pkg/usecases/dictionary_manager_usecase.go rename to backend/pkg/usecases/dictionary_manager/dictionary_manager_usecase.go index b5107f0..31ecbef 100644 --- a/backend/pkg/usecases/dictionary_manager_usecase.go +++ b/backend/pkg/usecases/dictionary_manager/dictionary_manager_usecase.go @@ -1,4 +1,4 @@ -package usecases +package dictionary_manager import ( "backend/graph/model" @@ -41,7 +41,6 @@ func (dmu *dictionaryManagerUsecase) UpsertCards(ctx context.Context, encodedDic return nil, goerr.Wrap(fmt.Errorf("failed to process dictionary: %+v", errs)) } - // Convert nodes to []model.Card var cards []model.Card for _, node := range nodes { card := model.Card{ diff --git a/backend/pkg/usecases/swipe_manager/default_state_strategy.go b/backend/pkg/usecases/swipe_manager/default_state_strategy.go index fa601d1..a346045 100644 --- a/backend/pkg/usecases/swipe_manager/default_state_strategy.go +++ b/backend/pkg/usecases/swipe_manager/default_state_strategy.go @@ -6,6 +6,7 @@ import ( "backend/pkg/config" "backend/pkg/logger" repo "backend/pkg/repository" + "github.com/m-mizutani/goerr" "golang.org/x/net/context" ) @@ -27,7 +28,8 @@ func NewDefaultStateStrategy( } } -func (d *defaultStateStrategy) Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (d *defaultStateStrategy) Run(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { // Fetch random recent added words cards, err := d.swipeManagerUsecase.Srv().GetRandomCardsFromRecentUpdates(ctx, newSwipeRecord.CardGroupID, config.Cfg.PGQueryLimit, repo.DESC, repo.ASC) diff --git a/backend/pkg/usecases/swipe_manager/difficult_state_strategy.go b/backend/pkg/usecases/swipe_manager/difficult_state_strategy.go index 84a4f6d..d2812bd 100644 --- a/backend/pkg/usecases/swipe_manager/difficult_state_strategy.go +++ b/backend/pkg/usecases/swipe_manager/difficult_state_strategy.go @@ -6,7 +6,10 @@ import ( "backend/graph/services" "backend/pkg/config" "backend/pkg/logger" + repo "backend/pkg/repository" + "github.com/m-mizutani/goerr" "golang.org/x/net/context" + "time" ) type difficultStateStrategy struct { @@ -25,9 +28,20 @@ func NewDifficultStateStrategy(swipeManagerUsecase SwipeManagerUsecase) Difficul } } -func (d *difficultStateStrategy) Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (d *difficultStateStrategy) Run(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { + // Past 1 week + fromDate := time.Now().AddDate(0, 0, -7) - return nil, nil + // Fetch random recent created card within a week. + cards, err := d.swipeManagerUsecase.Srv().GetRandomRecentCards( + ctx, fromDate, d.amountOfSwipes, repo.DESC) + + if err != nil { + return nil, goerr.Wrap(err) + } + + return cards, nil } func (d *difficultStateStrategy) IsApplicable(ctx context.Context, newSwipeRecord model.NewSwipeRecord, latestSwipeRecords []*repository.SwipeRecord) bool { diff --git a/backend/pkg/usecases/swipe_manager/easy_state_strategy.go b/backend/pkg/usecases/swipe_manager/easy_state_strategy.go index 97de543..120765c 100644 --- a/backend/pkg/usecases/swipe_manager/easy_state_strategy.go +++ b/backend/pkg/usecases/swipe_manager/easy_state_strategy.go @@ -27,7 +27,8 @@ func NewEasyStateStrategy(swipeManagerUsecase SwipeManagerUsecase) EasyStateStra } } -func (e *easyStateStrategy) Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (e *easyStateStrategy) Run(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { // Fetch random unknown words cards, err := e.swipeManagerUsecase.Srv().GetRandomCardsFromRecentUpdates(ctx, newSwipeRecord.CardGroupID, config.Cfg.PGQueryLimit, repo.ASC, repo.ASC) diff --git a/backend/pkg/usecases/swipe_manager/good_state_strategy.go b/backend/pkg/usecases/swipe_manager/good_state_strategy.go index 3d9502a..a97a6fc 100644 --- a/backend/pkg/usecases/swipe_manager/good_state_strategy.go +++ b/backend/pkg/usecases/swipe_manager/good_state_strategy.go @@ -28,7 +28,8 @@ func NewGoodStateStrategy(swipeManagerUsecase SwipeManagerUsecase) GoodStateStra } // Run ChangeState changes the state of the given swipe records to GOOD -func (g *goodStateStrategy) Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (g *goodStateStrategy) Run(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { return nil, nil } diff --git a/backend/pkg/usecases/swipe_manager/inwhile_state_strategy.go b/backend/pkg/usecases/swipe_manager/inwhile_state_strategy.go index e2ec7c3..a68178a 100644 --- a/backend/pkg/usecases/swipe_manager/inwhile_state_strategy.go +++ b/backend/pkg/usecases/swipe_manager/inwhile_state_strategy.go @@ -28,7 +28,8 @@ func NewInWhileStateStrategy(swipeManagerUsecase SwipeManagerUsecase) InWhileSta } } -func (d *inWhileStateStrategy) Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (d *inWhileStateStrategy) Run(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { // Fetch random known words, sorting by the most recent updates cards, err := d.swipeManagerUsecase.Srv().GetRandomCardsFromRecentUpdates(ctx, newSwipeRecord.CardGroupID, config.Cfg.PGQueryLimit, repo.DESC, repo.DESC) if err != nil { diff --git a/backend/pkg/usecases/swipe_manager/swipe_manager_usecase.go b/backend/pkg/usecases/swipe_manager/swipe_manager_usecase.go index f92f241..f38c47f 100644 --- a/backend/pkg/usecases/swipe_manager/swipe_manager_usecase.go +++ b/backend/pkg/usecases/swipe_manager/swipe_manager_usecase.go @@ -49,9 +49,11 @@ type swipeManagerUsecase struct { } type SwipeManagerUsecase interface { - HandleSwipe(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) + HandleSwipe(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ( + []*model.Card, error) Srv() services.Services - DetermineCardAmount(cards []model.Card, amountOfKnownWords int) (int, error) + DetermineCardAmount(cards []*model.Card, amountOfKnownWords int) (int, + error) } func NewSwipeManagerUsecase( @@ -66,7 +68,8 @@ func (s *swipeManagerUsecase) Srv() services.Services { } // HandleSwipe Main function to execute state machine -func (s *swipeManagerUsecase) HandleSwipe(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) { +func (s *swipeManagerUsecase) HandleSwipe(ctx context.Context, + newSwipeRecord model.NewSwipeRecord) ([]*model.Card, error) { // Fetch latest swipe records latestSwipeRecords, err := s.Srv().GetSwipeRecordsByUserAndOrder(ctx, newSwipeRecord.UserID, repo.DESC, config.Cfg.FLBatchDefaultAmount) @@ -191,7 +194,8 @@ func (s *swipeManagerUsecase) getStrategy( } func (s *swipeManagerUsecase) ExecuteStrategy(ctx context.Context, - newSwipeRecord model.NewSwipeRecord, strategy SwipeStrategy) ([]model.Card, error) { + newSwipeRecord model.NewSwipeRecord, strategy SwipeStrategy) ( + []*model.Card, error) { // Change State cards, err := strategy.Run(ctx, newSwipeRecord) @@ -202,7 +206,9 @@ func (s *swipeManagerUsecase) ExecuteStrategy(ctx context.Context, return cards, nil } -func (s *swipeManagerUsecase) DetermineCardAmount(cards []model.Card, amountOfKnownWords int) (int, error) { +func (s *swipeManagerUsecase) DetermineCardAmount( + cards []*model.Card, + amountOfKnownWords int) (int, error) { cardAmount := amountOfKnownWords if len(cards) <= amountOfKnownWords { cardAmount = len(cards) - 1 diff --git a/backend/pkg/usecases/swipe_manager/swipe_manager_usecase_test.go b/backend/pkg/usecases/swipe_manager/swipe_manager_usecase_test.go index a9900f0..14c9598 100644 --- a/backend/pkg/usecases/swipe_manager/swipe_manager_usecase_test.go +++ b/backend/pkg/usecases/swipe_manager/swipe_manager_usecase_test.go @@ -335,7 +335,7 @@ func (suite *SwipeManagerTestSuite) TestUpdateRecords() { createdGroup, _, _ := testutils.CreateUserAndCardGroup(ctx, suite.userService, suite.cardGroupService, suite.roleService) // Create 15 dummy cards with the same cardgroup_id and store them in a slice - var cards []model.Card + var cards []*model.Card for i := 0; i < 15; i++ { input := model.NewCard{ Front: "Front " + strconv.Itoa(i), @@ -347,7 +347,7 @@ func (suite *SwipeManagerTestSuite) TestUpdateRecords() { assert.NoError(suite.T(), err) // Convert the created card to model.Card and append to the slice - cards = append(cards, *card) + cards = append(cards, card) } amountOfKnownWords := 5 @@ -362,7 +362,7 @@ func (suite *SwipeManagerTestSuite) TestUpdateRecords() { suite.Run("Error_NoCardsAvailable", func() { // Arrange - var cards []model.Card + var cards []*model.Card amountOfKnownWords := 5 // Act diff --git a/backend/pkg/usecases/swipe_manager/swipe_strategy.go b/backend/pkg/usecases/swipe_manager/swipe_strategy.go index 45f1c90..8c5b51a 100644 --- a/backend/pkg/usecases/swipe_manager/swipe_strategy.go +++ b/backend/pkg/usecases/swipe_manager/swipe_strategy.go @@ -7,6 +7,7 @@ import ( ) type SwipeStrategy interface { - Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ([]model.Card, error) + Run(ctx context.Context, newSwipeRecord model.NewSwipeRecord) ( + []*model.Card, error) IsApplicable(ctx context.Context, newSwipeRecord model.NewSwipeRecord, latestSwipeRecords []*repository.SwipeRecord) bool } diff --git a/backend/pkg/usecases/usecase.go b/backend/pkg/usecases/usecase.go index 290af47..9c4cce3 100644 --- a/backend/pkg/usecases/usecase.go +++ b/backend/pkg/usecases/usecase.go @@ -3,25 +3,26 @@ package usecases import ( "backend/graph/services" "backend/pkg/textdic" + "backend/pkg/usecases/dictionary_manager" "backend/pkg/usecases/swipe_manager" ) // Usecases interface aggregates all usecases interfaces type Usecases interface { - DictionaryManagerUsecase + dictionary_manager.DictionaryManagerUsecase swipe_manager.SwipeManagerUsecase } // usecases struct holds references to all usecases implementations type usecases struct { - DictionaryManagerUsecase + dictionary_manager.DictionaryManagerUsecase swipe_manager.SwipeManagerUsecase } // New creates a new instance of Usecases with the provided services func New(sv services.Services) Usecases { return &usecases{ - DictionaryManagerUsecase: NewDictionaryManagerUsecase( + DictionaryManagerUsecase: dictionary_manager.NewDictionaryManagerUsecase( sv.(services.CardService), textdic.NewTextDictionaryService()), SwipeManagerUsecase: swipe_manager.NewSwipeManagerUsecase(sv), }