diff --git a/Makefile b/Makefile index c229e8d..4975508 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,12 @@ -.PHONY: build run generate test +.PHONY: + build + build-all + run + generate + test + lint + lint-fix + demo build: generate @go build -v -o 2sp ./cmd/2sp diff --git a/internal/testcommon/suite.go b/internal/testcommon/suite.go index de30025..d76e5d4 100644 --- a/internal/testcommon/suite.go +++ b/internal/testcommon/suite.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/six78/2-story-points-cli/internal/config" + "github.com/six78/2-story-points-cli/pkg/protocol" ) type Suite struct { @@ -46,3 +47,10 @@ func (s *Suite) FakePayload() ([]byte, []byte) { return payload, jsonPayload } + +func (s *Suite) FakeIssue() *protocol.Issue { + issue := &protocol.Issue{} + err := gofakeit.Struct(&issue) + s.Require().NoError(err) + return issue +} diff --git a/internal/view/DESIGN.md b/internal/view/DESIGN.md index 08df4b2..a9bd4f8 100644 --- a/internal/view/DESIGN.md +++ b/internal/view/DESIGN.md @@ -11,18 +11,18 @@ Author: @Alice [LanguageChange] [v2] [Proposal] Assignee: @Bob [NeedsInvestigation] - + ╭───────┬───────────┬─────────┬───────┬──────╮ - │ Alice │ Bob (You) │ Charlie │ David │ Erin │ Recommended: 8 - ├───────┼───────────┼─────────┼───────┼──────┤ Acceptable: ✓ - │ 8 │ 8 │ 5 │ 5 │ 8 │ > Not bad. + │ Alice │ Bob (You) │ Charlie │ David │ Erin │ + ├───────┼───────────┼─────────┼───────┼──────┤ + │ 8 │ 8 │ 5 │ 5 │ 8 │ Revealing in 3.0 ╰───────┴───────────┴─────────┴───────┴──────╯ ╭───╮ - ╭───╮ ╭───╮ ╭───╮ ╭───╮ │ 8 │ ╭────╮ ╭────╮ ╭────╮ - │ 1 │ │ 2 │ │ 3 │ │ 5 │ ╰───╯ │ 13 │ │ 21 │ │ 34 │ - ╰───╯ ╰───╯ ╰───╯ ╰───╯ ╰────╯ ╰────╯ ╰────╯ + ╭───╮ ╭───╮ ╭───╮ ╭───╮ │ 8 │ ╭────╮ ╭────╮ ╭───╮ + │ 1 │ │ 2 │ │ 3 │ │ 5 │ ╰───╯ │ 13 │ │ 21 │ │ ? │ + ╰───╯ ╰───╯ ╰───╯ ╰───╯ ╰────╯ ╰────╯ ╰───╯ ^ - + Use [←] and [→] arrows to select a card and press [Enter] [Tab] To switch to issues list view [C] Switch to command mode [Q] Leave room [E] Exit [H] Help @@ -192,7 +192,7 @@ Therefore the card can be in one of these 4 states: -For example for a Fibbonacci deck +For example for a Fibonacci deck ```shell Your vote: diff --git a/internal/view/actions.go b/internal/view/actions.go index 729d6a2..b3a881d 100644 --- a/internal/view/actions.go +++ b/internal/view/actions.go @@ -7,46 +7,47 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" + "golang.org/x/exp/slices" + "github.com/six78/2-story-points-cli/internal/view/commands" "github.com/six78/2-story-points-cli/internal/view/messages" "github.com/six78/2-story-points-cli/internal/view/states" "github.com/six78/2-story-points-cli/pkg/game" "github.com/six78/2-story-points-cli/pkg/protocol" - "golang.org/x/exp/slices" ) type Action string const ( - Rename Action = "rename" - New Action = "new" - Join Action = "join" - Exit Action = "exit" - Vote Action = "vote" - Unvote Action = "unvote" - Deal Action = "deal" - Add Action = "add" - Reveal Action = "reveal" - Finish Action = "finish" - Deck Action = "deck" - Select Action = "select" + Rename Action = "rename" + New Action = "new" + Join Action = "join" + Exit Action = "exit" + Vote Action = "vote" + Retract Action = "retract" + Deal Action = "deal" + Add Action = "add" + Reveal Action = "reveal" + Finish Action = "finish" + Deck Action = "deck" + Select Action = "select" ) type actionFunc func(m *model, args []string) tea.Cmd var actions = map[Action]actionFunc{ - Rename: runRenameAction, - Vote: runVoteAction, - Unvote: runUnvoteAction, - Deal: runDealAction, - Add: runAddAction, - New: runNewAction, - Join: runJoinAction, - Exit: runExitAction, - Reveal: runRevealAction, - Finish: runFinishAction, - Deck: runDeckAction, - Select: runSelectAction, + Rename: runRenameAction, + Vote: runVoteAction, + Retract: runRetractAction, + Deal: runDealAction, + Add: runAddAction, + New: runNewAction, + Join: runJoinAction, + Exit: runExitAction, + Reveal: runRevealAction, + Finish: runFinishAction, + Deck: runDeckAction, + Select: runSelectAction, } func processPlayerNameInput(m *model, playerName string) tea.Cmd { @@ -93,9 +94,9 @@ func runVoteAction(m *model, args []string) tea.Cmd { } } -func runUnvoteAction(m *model, args []string) tea.Cmd { +func runRetractAction(m *model, args []string) tea.Cmd { return func() tea.Msg { - return commands.PublishVote(m.game, "")() + return commands.RetractVote(m.game)() } } diff --git a/internal/view/commands/commands.go b/internal/view/commands/commands.go index e321834..ddce154 100644 --- a/internal/view/commands/commands.go +++ b/internal/view/commands/commands.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/pkg/errors" + "github.com/six78/2-story-points-cli/internal/transport" "github.com/six78/2-story-points-cli/internal/view/messages" "github.com/six78/2-story-points-cli/internal/view/states" @@ -99,6 +100,19 @@ func PublishVote(game *game.Game, vote protocol.VoteValue) tea.Cmd { } } +func RetractVote(game *game.Game) tea.Cmd { + return func() tea.Msg { + err := game.RetractVote() + if err != nil { + return messages.NewErrorMessage(err) + } + // TODO: Send err=nil ErrorMessage here + return messages.MyVote{ + Result: game.MyVote(), + } + } +} + func FinishVoting(game *game.Game, result protocol.VoteValue) tea.Cmd { return func() tea.Msg { err := game.Finish(result) diff --git a/internal/view/components/hintview/model.go b/internal/view/components/hintview/model.go index 897b2ee..afb0323 100644 --- a/internal/view/components/hintview/model.go +++ b/internal/view/components/hintview/model.go @@ -53,11 +53,9 @@ func (m Model) View() string { } return lipgloss.JoinVertical(lipgloss.Top, - "", headerStyle.Render("Recommended:")+""+voteview.Render(m.hint.Value), headerStyle.Render("Acceptable:")+" "+renderAcceptanceIcon(m.hint.Acceptable), headerStyle.Render(">")+" "+textStyle.Render(m.hint.Description), - "", ) } diff --git a/internal/view/components/hintview/model_test.go b/internal/view/components/hintview/model_test.go index 6d874b9..a13ef8d 100644 --- a/internal/view/components/hintview/model_test.go +++ b/internal/view/components/hintview/model_test.go @@ -91,11 +91,9 @@ func TestUpdateAcceptableVote(t *testing.T) { } expectedLines := []string{ - "", "Recommended: " + string(issue.Hint.Value), "Acceptable: " + expectedAcceptableIcon, "> " + issue.Hint.Description, - "", } lines := strings.Split(model.View(), "\n") diff --git a/internal/view/components/issueview/issue_view.go b/internal/view/components/issueview/issue_view.go index 91626c0..6ce8f9b 100644 --- a/internal/view/components/issueview/issue_view.go +++ b/internal/view/components/issueview/issue_view.go @@ -20,7 +20,6 @@ var ( primaryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F0F0F0")) secondaryStyle = lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) hyperlinkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#648EF8")).Underline(true) - errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF1744")) ) const ( @@ -146,7 +145,7 @@ func (m *Model) renderTitle(info *issueInfo) string { } if info.err != nil { - return errorStyle.Render(fmt.Sprintf("[%s]", info.err.Error())) + return secondaryStyle.Render(fmt.Sprintf("[%s]", info.err.Error())) } if info.title == nil { diff --git a/internal/view/components/votestate/model.go b/internal/view/components/votestate/model.go new file mode 100644 index 0000000..09445e9 --- /dev/null +++ b/internal/view/components/votestate/model.go @@ -0,0 +1,47 @@ +package votestate + +import ( + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/six78/2-story-points-cli/internal/config" + "github.com/six78/2-story-points-cli/internal/view/messages" +) + +var style = lipgloss.NewStyle().Foreground(config.ForegroundShadeColor) + +type Model struct { + duration time.Duration + start time.Time +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case messages.AutoRevealScheduled: + m.start = time.Now() + m.duration = msg.Duration + case messages.AutoRevealCancelled: + m.start = time.Time{} + m.duration = 0 + } + + return m, nil +} + +func (m Model) View() string { + if m.start.IsZero() { + return "" + } + left := (m.duration - time.Since(m.start)).Seconds() + if left == 0 { + return style.Render("Revealing votes...") + } + return style.Render(fmt.Sprintf("Revealing in %.1f", left)) +} diff --git a/internal/view/messages/messages.go b/internal/view/messages/messages.go index 0b7aad9..c158ab6 100644 --- a/internal/view/messages/messages.go +++ b/internal/view/messages/messages.go @@ -1,6 +1,8 @@ package messages import ( + "time" + "github.com/six78/2-story-points-cli/internal/transport" "github.com/six78/2-story-points-cli/internal/view/states" "github.com/six78/2-story-points-cli/pkg/protocol" @@ -59,3 +61,10 @@ type MyVote struct { type EnableEnterKey struct { } + +type AutoRevealScheduled struct { + Duration time.Duration +} + +type AutoRevealCancelled struct { +} diff --git a/internal/view/model.go b/internal/view/model.go index b4f09e1..ae3cec5 100644 --- a/internal/view/model.go +++ b/internal/view/model.go @@ -25,6 +25,7 @@ import ( "github.com/six78/2-story-points-cli/internal/view/components/playersview" "github.com/six78/2-story-points-cli/internal/view/components/shortcutsview" "github.com/six78/2-story-points-cli/internal/view/components/userinput" + "github.com/six78/2-story-points-cli/internal/view/components/votestate" "github.com/six78/2-story-points-cli/internal/view/components/wakustatusview" "github.com/six78/2-story-points-cli/internal/view/messages" "github.com/six78/2-story-points-cli/internal/view/states" @@ -46,16 +47,18 @@ type model struct { connectionStatus transport.ConnectionStatus // UI components state - commandMode bool - roomViewState states.RoomView - errorView errorview.Model - playersView playersview.Model - hintView hintview.Model - shortcutsView shortcutsview.Model - wakuStatusView wakustatusview.Model - deckView deckview.Model - issueView issueview.Model - issuesListView issuesview.Model + commandMode bool + roomViewState states.RoomView + errorView errorview.Model + playersView playersview.Model + hintView hintview.Model + shortcutsView shortcutsview.Model + wakuStatusView wakustatusview.Model + deckView deckview.Model + issueView issueview.Model + issuesListView issuesview.Model + voteStateView votestate.Model + gameEventHandler eventhandler.Model[game.Event, interface{}] transportEventHandler eventhandler.Model[transport.ConnectionStatus, messages.ConnectionStatus] @@ -95,6 +98,7 @@ func InitialModel(game *game.Game, transport transport.Service) model { deckView: deckView, issueView: issueview.New(), issuesListView: issuesview.New(), + voteStateView: votestate.Model{}, // Other disableEnterKey: false, disableEnterRestart: nil, @@ -120,6 +124,7 @@ func (m model) Init() tea.Cmd { m.deckView.Init(), m.issueView.Init(), m.issuesListView.Init(), + m.voteStateView.Init(), commands.InitializeApp(m.game, m.transport), ) } @@ -286,6 +291,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.deckView = m.deckView.Update(msg) m.issueView, cmds.IssueViewCommand = m.issueView.Update(msg) m.issuesListView, cmds.IssuesListViewCommand = m.issuesListView.Update(msg) + m.voteStateView, _ = m.voteStateView.Update(msg) m.gameEventHandler, cmds.GameEventHandlerCommand = m.gameEventHandler.Update(msg) m.transportEventHandler, cmds.TransportEventHandlerCommand = m.transportEventHandler.Update(msg) @@ -400,6 +406,12 @@ func gameEventToMessage(event game.Event) interface{} { if state, ok := event.Data.(*protocol.State); ok { return messages.GameStateMessage{State: state} } + case game.EventAutoRevealScheduled: + if duration, ok := event.Data.(time.Duration); ok { + return messages.AutoRevealScheduled{Duration: duration} + } + case game.EventAutoRevealCancelled: + return messages.AutoRevealCancelled{} default: return nil } diff --git a/internal/view/render.go b/internal/view/render.go index b482021..43b964a 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -95,10 +95,17 @@ func (m model) renderRoomCurrentIssueView() string { ) } + playersView := m.playersView.View() + if m.gameState.VotesRevealed { + playersView = lipgloss.JoinHorizontal(lipgloss.Center, playersView, " ", m.hintView.View()) + } else if m.game.IsDealer() && m.gameState.AllPlayersVoted() { + playersView = lipgloss.JoinHorizontal(0.75, playersView, " ", m.voteStateView.View()) + } + return lipgloss.JoinVertical(lipgloss.Top, m.issueView.View(), "", - lipgloss.JoinHorizontal(lipgloss.Left, m.playersView.View(), " ", m.hintView.View()), + playersView, m.deckView.View(), ) } diff --git a/pkg/game/config.go b/pkg/game/config.go index d61b225..6b61a54 100644 --- a/pkg/game/config.go +++ b/pkg/game/config.go @@ -8,6 +8,8 @@ type configuration struct { OnlineMessagePeriod time.Duration StateMessagePeriod time.Duration PublishStateLoopEnabled bool + AutoRevealEnabled bool + AutoRevealDelay time.Duration } var defaultConfig = configuration{ @@ -16,4 +18,6 @@ var defaultConfig = configuration{ OnlineMessagePeriod: 5 * time.Second, StateMessagePeriod: 30 * time.Second, PublishStateLoopEnabled: true, + AutoRevealEnabled: true, + AutoRevealDelay: 2 * time.Second, } diff --git a/pkg/game/events.go b/pkg/game/events.go index b43ac4c..3be25b2 100644 --- a/pkg/game/events.go +++ b/pkg/game/events.go @@ -4,6 +4,8 @@ type EventTag int const ( EventStateChanged EventTag = iota + EventAutoRevealScheduled + EventAutoRevealCancelled ) type Event struct { diff --git a/pkg/game/game.go b/pkg/game/game.go index dea392c..e2f02ad 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "reflect" + "sync" "time" "github.com/jonboulle/clockwork" @@ -41,17 +42,20 @@ type Game struct { player *protocol.Player myVote protocol.VoteResult // We save our vote to show it in UI - room *protocol.Room - roomID protocol.RoomID - state *protocol.State - stateTimestamp int64 - events EventManager + room *protocol.Room + roomID protocol.RoomID + state *protocol.State + stateTimestamp int64 + events EventManager + revealTimer clockwork.Timer + revealTimerLock sync.Mutex } func NewGame(opts []Option) *Game { game := &Game{ exitRoom: nil, messages: make(chan []byte, 42), + config: defaultConfig, features: defaultFeatureFlags(), codeControls: defaultCodeControlFlags(), initialized: false, @@ -63,7 +67,6 @@ func NewGame(opts []Option) *Game { }, room: nil, stateTimestamp: 0, - config: defaultConfig, } for _, opt := range opts { @@ -206,6 +209,15 @@ func (g *Game) notifyChangedState(publish bool) { if publish { go g.publishState(state) } + + if g.config.AutoRevealEnabled { + if g.revealTimer != nil && (g.state != nil || !g.state.AllPlayersVoted()) { + g.cancelAutoReveal() + } + if g.state != nil && !g.state.VotesRevealed && g.state.AllPlayersVoted() { + g.scheduleAutoReveal() + } + } } func (g *Game) publishOnlineState() { @@ -396,7 +408,7 @@ func (g *Game) PublishVote(vote protocol.VoteValue) error { return nil } -func (g *Game) RetrieveVote() error { +func (g *Game) RetractVote() error { return g.PublishVote("") } @@ -608,6 +620,8 @@ func (g *Game) Reveal() error { return errors.New("cannot reveal when voting is not in progress") } + g.cancelAutoReveal() + g.state.VotesRevealed = true g.notifyChangedState(true) return nil @@ -748,6 +762,9 @@ func (g *Game) SelectIssue(index int) error { } func (g *Game) playerIndex(playerID protocol.PlayerID) int { + if g.state == nil { + return -1 + } return slices.IndexFunc(g.state.Players, func(player protocol.Player) bool { return player.ID == playerID }) @@ -832,3 +849,48 @@ func (g *Game) fillActiveIssueHint() { g.logger.Error("failed to generate hint", zap.Error(err)) } } + +func (g *Game) scheduleAutoReveal() { + g.revealTimerLock.Lock() + defer g.revealTimerLock.Unlock() + + g.logger.Debug("scheduling auto reveal") + + issueToReveal := g.state.ActiveIssue + + g.events.Send(Event{ + Tag: EventAutoRevealScheduled, + Data: g.config.AutoRevealDelay, + }) + + g.revealTimer = g.clock.AfterFunc(g.config.AutoRevealDelay, func() { + g.cancelAutoReveal() + go func() { + if g.state.ActiveIssue != issueToReveal { + g.logger.Debug("auto reveal cancelled: issue changed") + } + err := g.Reveal() + if err != nil { + g.logger.Warn("auto reveal failed", zap.Error(err)) + } + }() + }) +} + +func (g *Game) cancelAutoReveal() { + g.revealTimerLock.Lock() + defer g.revealTimerLock.Unlock() + + if g.revealTimer == nil { + return + } + + cancelled := g.revealTimer.Stop() + g.revealTimer = nil + + if cancelled { + g.events.Send(Event{ + Tag: EventAutoRevealCancelled, + }) + } +} diff --git a/pkg/game/game_test.go b/pkg/game/game_test.go index 731ef6a..65b3d43 100644 --- a/pkg/game/game_test.go +++ b/pkg/game/game_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "testing" + "time" "github.com/brianvoe/gofakeit/v6" "github.com/jonboulle/clockwork" @@ -135,8 +136,11 @@ func (s *Suite) TestStateSize() { } func (s *Suite) TestSimpleGame() { + const autoRevealDelay = 3 * time.Second + dealer := s.newGame([]Option{ WithEnableSymmetricEncryption(true), + WithAutoReveal(true, autoRevealDelay), }) room, initialState, err := dealer.CreateNewRoom() @@ -228,24 +232,75 @@ func (s *Suite) TestSimpleGame() { s.Require().Greater(vote.Timestamp, int64(0)) } - { // Reveal votes + { // Expect votes auto reveal, but cancel because retract dealer vote + s.clock.Advance(autoRevealDelay / 2) + + voteMatcher := matchers.NewVoteMatcher(dealer.Player().ID, currentIssue.ID, "") + s.transport.EXPECT(). + PublishPublicMessage(roomMatcher, voteMatcher). + Times(1) + + stateMatcher = s.newStateMatcher() + s.transport.EXPECT(). + PublishPublicMessage(roomMatcher, stateMatcher). + Times(1) + + err = dealer.RetractVote() + s.Require().NoError(err) + + state = stateMatcher.Wait() + item := checkIssues(state.Issues) + s.Require().Nil(item.Result) + s.Require().Empty(item.Votes) + } + + const newDealerVote = protocol.VoteValue("2") + + { // Publish dealer vote again + voteMatcher := matchers.NewVoteMatcher(dealer.Player().ID, currentIssue.ID, newDealerVote) + s.transport.EXPECT(). + PublishPublicMessage(roomMatcher, voteMatcher). + Times(1) + + stateMatcher = s.newStateMatcher() + s.transport.EXPECT(). + PublishPublicMessage(roomMatcher, stateMatcher). + Times(1) + + err = dealer.PublishVote(newDealerVote) + s.Require().NoError(err) + + state = stateMatcher.Wait() + item := checkIssues(state.Issues) + s.Require().NotNil(item) + s.Require().Nil(item.Result) + s.Require().Len(item.Votes, 1) + + vote, ok := item.Votes[dealer.Player().ID] + s.Require().True(ok) + s.Require().Empty(vote.Value) + s.Require().Greater(vote.Timestamp, int64(0)) + } + + { // Auto-revealed votes stateMatcher = s.newStateMatcher() s.transport.EXPECT(). PublishPublicMessage(roomMatcher, stateMatcher). Times(1) - err = dealer.Reveal() + s.clock.Advance(autoRevealDelay) s.Require().NoError(err) state = stateMatcher.Wait() item := checkIssues(state.Issues) s.Require().Nil(item.Result) s.Require().Len(item.Votes, 1) + s.Require().True(state.VotesRevealed) vote, ok := item.Votes[dealer.Player().ID] s.Require().True(ok) s.Require().NotNil(vote) - s.Require().Equal(dealerVote, vote.Value) + s.Require().Equal(newDealerVote, vote.Value) s.Require().Greater(vote.Timestamp, int64(0)) } @@ -268,7 +323,7 @@ func (s *Suite) TestSimpleGame() { vote, ok := item.Votes[dealer.Player().ID] s.Require().True(ok) - s.Require().Equal(dealerVote, vote.Value) + s.Require().Equal(newDealerVote, vote.Value) s.Require().Greater(vote.Timestamp, int64(0)) } diff --git a/pkg/game/options.go b/pkg/game/options.go index 2b16c0a..4f9c5ef 100644 --- a/pkg/game/options.go +++ b/pkg/game/options.go @@ -5,9 +5,10 @@ import ( "time" "github.com/jonboulle/clockwork" + "go.uber.org/zap" + "github.com/six78/2-story-points-cli/internal/transport" "github.com/six78/2-story-points-cli/pkg/storage" - "go.uber.org/zap" ) type Option func(*Game) @@ -71,3 +72,10 @@ func WithPublishStateLoop(enabled bool) Option { g.config.PublishStateLoopEnabled = enabled } } + +func WithAutoReveal(enabled bool, delay time.Duration) Option { + return func(g *Game) { + g.config.AutoRevealEnabled = enabled + g.config.AutoRevealDelay = delay + } +} diff --git a/pkg/game/options_test.go b/pkg/game/options_test.go index f4f233e..1f8570f 100644 --- a/pkg/game/options_test.go +++ b/pkg/game/options_test.go @@ -7,11 +7,12 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/jonboulle/clockwork" - mocktransport "github.com/six78/2-story-points-cli/internal/transport/mock" - mockstorage "github.com/six78/2-story-points-cli/pkg/storage/mock" "github.com/stretchr/testify/require" "go.uber.org/zap" "go.uber.org/zap/zapcore" + + mocktransport "github.com/six78/2-story-points-cli/internal/transport/mock" + mockstorage "github.com/six78/2-story-points-cli/pkg/storage/mock" ) func TestOptions(t *testing.T) { @@ -26,6 +27,8 @@ func TestOptions(t *testing.T) { onlineMessagePeriod := time.Duration(gofakeit.Int64()) stateMessagePeriod := time.Duration(gofakeit.Int64()) publishStateLoop := gofakeit.Bool() + autoRevealEnabled := gofakeit.Bool() + autoRevealDelay := time.Duration(gofakeit.Int64()) options := []Option{ WithContext(ctx), @@ -38,6 +41,7 @@ func TestOptions(t *testing.T) { WithOnlineMessagePeriod(onlineMessagePeriod), WithStateMessagePeriod(stateMessagePeriod), WithPublishStateLoop(publishStateLoop), + WithAutoReveal(autoRevealEnabled, autoRevealDelay), } game := NewGame(options) @@ -52,6 +56,8 @@ func TestOptions(t *testing.T) { require.Equal(t, onlineMessagePeriod, game.config.OnlineMessagePeriod) require.Equal(t, stateMessagePeriod, game.config.StateMessagePeriod) require.Equal(t, publishStateLoop, game.config.PublishStateLoopEnabled) + require.Equal(t, autoRevealEnabled, game.config.AutoRevealEnabled) + require.Equal(t, autoRevealDelay, game.config.AutoRevealDelay) } func TestNoTransport(t *testing.T) { diff --git a/pkg/protocol/state.go b/pkg/protocol/state.go index 1967c5a..12927b0 100644 --- a/pkg/protocol/state.go +++ b/pkg/protocol/state.go @@ -1,6 +1,10 @@ package protocol -import "github.com/six78/2-story-points-cli/internal/config" +import ( + "golang.org/x/exp/maps" + + "github.com/six78/2-story-points-cli/internal/config" +) type State struct { Players PlayersList `json:"players"` @@ -52,3 +56,8 @@ func (s *State) ActiveIssueHintDeckIndex() int { } return s.Deck.Index(issue.Hint.Value) } + +func (s *State) AllPlayersVoted() bool { + issue := s.GetActiveIssue() + return issue != nil && len(maps.Keys(issue.Votes)) == len(s.Players) +}