diff --git a/src/bowling_game/entity/bowling_game.go b/src/bowling_game/entity/bowling_game.go new file mode 100644 index 0000000..ffb2f7f --- /dev/null +++ b/src/bowling_game/entity/bowling_game.go @@ -0,0 +1,148 @@ +package entity + +import ( + "fmt" + + core "github.com/cs-lexliu/practice-event-sourcing/src/core/entity" +) + +type BowlingGame struct { + *core.AggregateRootTemplate + gameID string + score int + leavingPins int + bonusRemainHit int + bonusChance []int + finishedFrameCount int + bonusFrame int +} + +func newBowlingGame() *BowlingGame { + bowlingGame := &BowlingGame{} + bowlingGame.AggregateRootTemplate = core.NewAggregateRootTemple(bowlingGame) + return bowlingGame +} + +func NewBowlingGame(gameID string) *BowlingGame { + bowlingGame := newBowlingGame() + bowlingGame.Apply(NewBowlingGameCreated(gameID)) + return bowlingGame +} + +func NewBowlingGameFromEvent(domainEvents []core.DomainEvent) *BowlingGame { + bowlingGame := newBowlingGame() + for _, event := range domainEvents { + bowlingGame.Apply(event) + bowlingGame.ClearDomainEvents() + } + return bowlingGame +} + +var BowlingGameConstuctor core.Constuctor = func(events []core.DomainEvent) interface{} { + return NewBowlingGameFromEvent(events) +} + +func (b BowlingGame) Replay(domainEvents []core.DomainEvent) core.AggregateRoot { + bowlingGame := newBowlingGame() + for _, event := range domainEvents { + bowlingGame.Apply(event) + bowlingGame.ClearDomainEvents() + } + return bowlingGame +} + +func (b BowlingGame) ID() string { + return b.gameID +} + +func (b BowlingGame) Category() string { + return "BowlingGame" +} + +func (b BowlingGame) Score() int { + return b.score +} + +func (b BowlingGame) LeavingPins() int { + return b.leavingPins +} + +func (b BowlingGame) BonusRemainHit() int { + return b.bonusRemainHit +} + +func (b BowlingGame) BonusChance() []int { + cloneBonusChance := make([]int, len(b.bonusChance)) + copy(cloneBonusChance, b.bonusChance) + return cloneBonusChance +} + +func (b BowlingGame) FinishedFrameCount() int { + return b.finishedFrameCount +} + +func (b BowlingGame) BonusFrame() int { + return b.bonusFrame +} + +func (b *BowlingGame) RollABall(hit int) error { + if (b.finishedFrameCount-b.bonusFrame > 9) || + (b.finishedFrameCount > 9 && (b.bonusFrame == 1 && b.bonusRemainHit == 1)) { + return fmt.Errorf("unavailable to roll a ball after game finished") + } + if hit < 0 { + return fmt.Errorf("hit number should not less than 0") + } + b.Apply(NewBowlingGameRollABall(hit)) + return nil +} + +func (b *BowlingGame) When(domainEvent core.DomainEvent) { + switch event := interface{}(domainEvent).(type) { + case BowlingGameCreated: + b.gameID = event.gameID + b.leavingPins = 10 + b.bonusRemainHit = 2 + case BowlingGameRolledABall: + b.score += event.hit + b.leavingPins -= event.hit + b.bonusRemainHit-- + for i, chance := range b.bonusChance { + if chance > 0 { + b.score += event.hit + b.bonusChance[i] = chance - 1 + } + } + if frameHasBonusChance(b.finishedFrameCount) { + if strike(b.leavingPins, b.bonusRemainHit) { + b.bonusChance = append(b.bonusChance, 2) + } + if spare(b.leavingPins, b.bonusRemainHit) { + b.bonusChance = append(b.bonusChance, 1) + } + } + if spare(b.leavingPins, b.bonusRemainHit) && b.finishedFrameCount == 9 { + b.bonusFrame++ + } + if strike(b.leavingPins, b.bonusRemainHit) && b.finishedFrameCount >= 9 { + b.bonusFrame++ + } + if b.leavingPins == 0 || b.bonusRemainHit == 0 { + b.finishedFrameCount++ + b.leavingPins = 10 + b.bonusRemainHit = 2 + } + } +} + +func frameHasBonusChance(finishedFrameCount int) bool { + return finishedFrameCount < 9 +} + +func strike(leavingPins, bonusRemainHit int) bool { + return leavingPins == 0 && bonusRemainHit == 1 +} + +func spare(leavingPins, bonusRemainHit int) bool { + return leavingPins == 0 && bonusRemainHit == 0 +} diff --git a/src/bowling_game/entity/bowling_game_events.go b/src/bowling_game/entity/bowling_game_events.go new file mode 100644 index 0000000..8793c58 --- /dev/null +++ b/src/bowling_game/entity/bowling_game_events.go @@ -0,0 +1,40 @@ +package entity + +import core "github.com/cs-lexliu/practice-event-sourcing/src/core/entity" + +type BowlingGameEvents struct { + core.DomainEvent + name string +} + +func (e BowlingGameEvents) String() string { + return e.name +} + +type BowlingGameCreated struct { + BowlingGameEvents + gameID string +} + +func NewBowlingGameCreated(gameID string) BowlingGameCreated { + return BowlingGameCreated{ + BowlingGameEvents: BowlingGameEvents{ + name: "BowlingGameCreated", + }, + gameID: gameID, + } +} + +type BowlingGameRolledABall struct { + BowlingGameEvents + hit int +} + +func NewBowlingGameRollABall(hit int) BowlingGameRolledABall { + return BowlingGameRolledABall{ + BowlingGameEvents: BowlingGameEvents{ + name: "BowlingGameRolledABall", + }, + hit: hit, + } +} diff --git a/src/bowling_game/entity/bowling_game_test.go b/src/bowling_game/entity/bowling_game_test.go new file mode 100644 index 0000000..35e8265 --- /dev/null +++ b/src/bowling_game/entity/bowling_game_test.go @@ -0,0 +1,29 @@ +package entity + +import ( + "testing" + + core "github.com/cs-lexliu/practice-event-sourcing/src/core/entity" + "github.com/stretchr/testify/assert" +) + +func TestCreateBowlingGameGenerateABowlingGameCreatedEvent(t *testing.T) { + bowlingGame := NewBowlingGame("") + assert.Equal(t, 1, len(bowlingGame.DomainEvents())) + assert.IsType(t, BowlingGameCreated{}, bowlingGame.DomainEvents()[0]) +} + +func TestReplayBowlingGameFromBowlingGameCreatedEvent(t *testing.T) { + event := NewBowlingGameCreated("abc") + bowlingGame := NewBowlingGameFromEvent([]core.DomainEvent{event}) + assert.Equal(t, event.gameID, bowlingGame.ID()) + assert.Equal(t, 0, len(bowlingGame.DomainEvents())) +} + +func TestThrowCommandGenerateBowlingGameThrownEvent(t *testing.T) { + event := NewBowlingGameCreated("abc") + bowlingGame := NewBowlingGameFromEvent([]core.DomainEvent{event}) + bowlingGame.RollABall(1) + assert.Equal(t, 1, len(bowlingGame.DomainEvents())) + assert.IsType(t, BowlingGameRolledABall{}, bowlingGame.DomainEvents()[0]) +} diff --git a/src/bowling_game/usecase/create_bowling_game.go b/src/bowling_game/usecase/create_bowling_game.go new file mode 100644 index 0000000..9b21292 --- /dev/null +++ b/src/bowling_game/usecase/create_bowling_game.go @@ -0,0 +1,28 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/entity" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/in" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/out" +) + +type CreateBowlingGameService struct { + repository out.BowlingGameRepository +} + +func NewCreateBowlingGameService(repository out.BowlingGameRepository) *CreateBowlingGameService { + return &CreateBowlingGameService{ + repository: repository, + } +} + +func (s *CreateBowlingGameService) execute(ctx context.Context, input in.CreateBowlingGameInput) (string, error) { + bowlingGame := entity.NewBowlingGame(input.GameID) + if err := s.repository.Save(ctx, bowlingGame); err != nil { + return "", fmt.Errorf("repository save bowling game: %w", err) + } + return bowlingGame.ID(), nil +} diff --git a/src/bowling_game/usecase/create_bowling_game_test.go b/src/bowling_game/usecase/create_bowling_game_test.go new file mode 100644 index 0000000..eebdc98 --- /dev/null +++ b/src/bowling_game/usecase/create_bowling_game_test.go @@ -0,0 +1,47 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "github.com/cs-lexliu/practice-event-sourcing/src/adpater/inmem/pubsub" + "github.com/cs-lexliu/practice-event-sourcing/src/adpater/inmem/statestore" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/entity" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/in" + core "github.com/cs-lexliu/practice-event-sourcing/src/core/entity" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestCreateBowlingGameUseCase(t *testing.T) { + ctx := context.Background() + + eventBus := pubsub.NewDomainEventBus() + listener := &fakeCreateBowlingGameListener{} + subscriber := pubsub.NewSubscriber("event-listener", listener.Notifier) + eventBus.Register(ctx, subscriber) + + repository := statestore.NewStateRepositoryAdapter[*entity.BowlingGame](eventBus) + createBowlingGameUseCase := NewCreateBowlingGameService(repository) + input := in.CreateBowlingGameInput{ + GameID: uuid.New().String(), + } + + output, err := createBowlingGameUseCase.execute(ctx, input) + assert.NoError(t, err) + _, err = repository.FindByID(ctx, output) + assert.NoError(t, err) + + assert.Eventually(t, func() bool { + return assert.Equal(t, 1, listener.count) + }, 5*time.Millisecond, 1*time.Millisecond) +} + +type fakeCreateBowlingGameListener struct { + count int +} + +func (l *fakeCreateBowlingGameListener) Notifier(event core.DomainEvent) { + l.count++ +} diff --git a/src/bowling_game/usecase/port/in/create_bowling_game_input.go b/src/bowling_game/usecase/port/in/create_bowling_game_input.go new file mode 100644 index 0000000..014312e --- /dev/null +++ b/src/bowling_game/usecase/port/in/create_bowling_game_input.go @@ -0,0 +1,5 @@ +package in + +type CreateBowlingGameInput struct { + GameID string +} diff --git a/src/bowling_game/usecase/port/in/roll_a_ball_input.go b/src/bowling_game/usecase/port/in/roll_a_ball_input.go new file mode 100644 index 0000000..eb23579 --- /dev/null +++ b/src/bowling_game/usecase/port/in/roll_a_ball_input.go @@ -0,0 +1,6 @@ +package in + +type RollABallInput struct { + GameID string + Hit int +} diff --git a/src/bowling_game/usecase/port/out/bowling_game_repository.go b/src/bowling_game/usecase/port/out/bowling_game_repository.go new file mode 100644 index 0000000..ddf7195 --- /dev/null +++ b/src/bowling_game/usecase/port/out/bowling_game_repository.go @@ -0,0 +1,8 @@ +package out + +import ( + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/entity" + core "github.com/cs-lexliu/practice-event-sourcing/src/core/usecase" +) + +type BowlingGameRepository core.Repository[*entity.BowlingGame] diff --git a/src/bowling_game/usecase/roll_a_ball.go b/src/bowling_game/usecase/roll_a_ball.go new file mode 100644 index 0000000..cff2f9c --- /dev/null +++ b/src/bowling_game/usecase/roll_a_ball.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + "fmt" + + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/in" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/out" +) + +type RollABallService struct { + repository out.BowlingGameRepository +} + +func NewRollABallServiceService(repository out.BowlingGameRepository) *RollABallService { + return &RollABallService{ + repository: repository, + } +} + +func (s *RollABallService) execute(ctx context.Context, input in.RollABallInput) (string, error) { + bowlingGame, err := s.repository.FindByID(ctx, input.GameID) + if err != nil { + return "", fmt.Errorf("repository find bowling game by id: %w", err) + } + if err := bowlingGame.RollABall(input.Hit); err != nil { + return "", fmt.Errorf("bowling game roll a ball: %w", err) + } + if err := s.repository.Save(ctx, bowlingGame); err != nil { + return "", fmt.Errorf("repository save bowling game: %w", err) + } + return bowlingGame.ID(), nil +} diff --git a/src/bowling_game/usecase/roll_a_ball_test.go b/src/bowling_game/usecase/roll_a_ball_test.go new file mode 100644 index 0000000..31f5fab --- /dev/null +++ b/src/bowling_game/usecase/roll_a_ball_test.go @@ -0,0 +1,268 @@ +package usecase + +import ( + "context" + "testing" + "time" + + "github.com/cs-lexliu/practice-event-sourcing/src/adpater/inmem/eventstore" + "github.com/cs-lexliu/practice-event-sourcing/src/adpater/inmem/pubsub" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/entity" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/in" + "github.com/cs-lexliu/practice-event-sourcing/src/bowling_game/usecase/port/out" + coreentity "github.com/cs-lexliu/practice-event-sourcing/src/core/entity" + coreusecase "github.com/cs-lexliu/practice-event-sourcing/src/core/usecase" + "github.com/google/uuid" + "github.com/stretchr/testify/suite" +) + +type RollABallUseCaseTestSuite struct { + suite.Suite + + eventBus coreusecase.DomainEventBus + listener *fakeRollABallListener + repository out.BowlingGameRepository +} + +func TestRollABallUseCaseTestSuite(t *testing.T) { + suite.Run(t, &RollABallUseCaseTestSuite{}) +} + +func (s *RollABallUseCaseTestSuite) SetupTest() { + ctx := context.Background() + + coreentity.RegisterConstructor(entity.BowlingGame{}.Category(), entity.BowlingGameConstuctor) + + s.listener = &fakeRollABallListener{} + + eventBus := pubsub.NewDomainEventBus() + subscriber := pubsub.NewSubscriber("event-listener", s.listener.Notifier) + eventBus.Register(ctx, subscriber) + s.eventBus = eventBus + + //s.repository = statestore.NewStateRepositoryAdapter[*entity.BowlingGame](eventBus) + s.repository = eventstore.NewEventRepositoryAdapter[*entity.BowlingGame](eventBus) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallUseCase() { + gameID := s.createBowlingGame() + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + input := in.RollABallInput{ + GameID: gameID, + Hit: 1, + } + + gotGameID, err := rollABallUseCase.execute(ctx, input) + s.NoError(err) + s.Equal(gameID, gotGameID) + _, err = s.repository.FindByID(ctx, gotGameID) + s.NoError(err) + + s.Eventually(func() bool { + return s.Equal(1, s.listener.count) + }, 5*time.Millisecond, 1*time.Millisecond) +} + +func (s *RollABallUseCaseTestSuite) TestRollTwentyBallHasTwentyScore() { + gameID := s.createBowlingGame() + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + input := in.RollABallInput{ + GameID: gameID, + Hit: 1, + } + + for i := 0; i < 20; i++ { + _, _ = rollABallUseCase.execute(ctx, input) + } + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(20, output.Score()) +} + +func (s *RollABallUseCaseTestSuite) TestRollTwentyOneBallHasTwentyScore() { + gameID := s.createBowlingGame() + s.rollManyBall(gameID, 1, 20) + + ctx := context.Background() + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(20, output.Score()) +} + +func (s *RollABallUseCaseTestSuite) TestUnableToRollABallAfterGameFinished_TwentyBallWithoutSpareAndStrike() { + gameID := s.createBowlingGame() + s.rollManyBall(gameID, 1, 20) + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, err := rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + s.Error(err) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallShouldNotHasNegativeHitValue() { + gameID := s.createBowlingGame() + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + input := in.RollABallInput{ + GameID: gameID, + Hit: -1, + } + _, err := rollABallUseCase.execute(ctx, input) + s.Error(err) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallWithSpareHasNoLeavingPinsAndNotBonusRemainHit() { + gameID := s.createBowlingGame() + s.hasSpare(gameID) + + ctx := context.Background() + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(10, output.LeavingPins()) + s.Equal(2, output.BonusRemainHit()) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallAfterSpareHasBonusScoreAndLeavingPinsIsRestored() { + gameID := s.createBowlingGame() + s.hasSpare(gameID) + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(12, output.Score()) + s.Equal(9, output.LeavingPins()) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallWithStrikeHasNoLeavingPinsAndOneBonusRemainHit() { + gameID := s.createBowlingGame() + s.hasStrike(gameID) + + ctx := context.Background() + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(10, output.LeavingPins()) + s.Equal(2, output.BonusRemainHit()) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallAfterStrikeHasTwoBonusScoreAndLeavingPinsIsRestored() { + gameID := s.createBowlingGame() + s.hasStrike(gameID) + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(14, output.Score()) + s.Equal(10, output.LeavingPins()) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallWithAPerfectGame() { + gameID := s.createBowlingGame() + s.hasManyStrike(gameID, 12) + + ctx := context.Background() + output, err := s.repository.FindByID(ctx, gameID) + s.NoError(err) + s.Equal(300, output.Score()) +} + +func (s *RollABallUseCaseTestSuite) TestRollABallWithSpareInFinalFrameHasOneBonusFrame() { + gameID := s.createBowlingGame() + s.hasManySpare(gameID, 10) + + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, err := rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + s.NoError(err) +} + +func (s *RollABallUseCaseTestSuite) createBowlingGame() string { + createBowlingGameUseCase := NewCreateBowlingGameService(s.repository) + gameID := uuid.New().String() + input := in.CreateBowlingGameInput{ + GameID: gameID, + } + + _, _ = createBowlingGameUseCase.execute(context.Background(), input) + return gameID +} + +func (s *RollABallUseCaseTestSuite) rollManyBall(gameID string, hit int, times int) { + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + for i := 0; i < times; i++ { + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: hit, + }) + } +} + +func (s *RollABallUseCaseTestSuite) hasManySpare(gameID string, times int) { + for i := 0; i < times; i++ { + s.hasSpare(gameID) + } +} + +func (s *RollABallUseCaseTestSuite) hasSpare(gameID string) { + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 1, + }) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 9, + }) +} + +func (s *RollABallUseCaseTestSuite) hasManyStrike(gameID string, times int) { + for i := 0; i < times; i++ { + s.hasStrike(gameID) + } +} + +func (s *RollABallUseCaseTestSuite) hasStrike(gameID string) { + ctx := context.Background() + rollABallUseCase := NewRollABallServiceService(s.repository) + _, _ = rollABallUseCase.execute(ctx, in.RollABallInput{ + GameID: gameID, + Hit: 10, + }) +} + +type fakeRollABallListener struct { + count int +} + +func (l *fakeRollABallListener) Notifier(domainEvent coreentity.DomainEvent) { + switch (domainEvent).(type) { + case entity.BowlingGameRolledABall: + l.count++ + } +}