-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
df9939c
commit 9448c6f
Showing
5 changed files
with
308 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
package game | ||
|
||
import ( | ||
"math" | ||
"sort" | ||
|
||
"github.com/pkg/errors" | ||
"golang.org/x/exp/slices" | ||
|
||
"github.com/six78/2-story-points-cli/pkg/protocol" | ||
) | ||
|
||
type hintMeasurements struct { | ||
// median is the index median value of given votes | ||
median int | ||
|
||
// meanDeviation is the mean absolute deviation around the median | ||
// Measured in cards count. | ||
meanDeviation float64 | ||
|
||
// maxDeviation is the maximum absolute deviation from the median | ||
// Measured in cards count. | ||
maxDeviation float64 | ||
} | ||
|
||
const ( | ||
maxAcceptableMaximumDeviation = 1 | ||
maxAcceptableMeanDeviation = 0.5 | ||
// Rejection reasons | ||
varietyOfVotesIsTooHigh = "Variety of votes is too high" | ||
maximumDeviationIsTooHigh = "Maximum deviation is too high" | ||
) | ||
|
||
var ( | ||
ErrVoteNotFoundInDeck = errors.New("vote not found in deck") | ||
) | ||
|
||
func GetResultHint(deck protocol.Deck, issueVotes protocol.IssueVotes) (*protocol.Hint, error) { | ||
// Get votes as deck indexes. | ||
// We ignore the actual deck values when calculating the hint. | ||
indexes, err := getVotesAsDeckIndexes(issueVotes, deck) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
// Calculate measures for the votes | ||
resultMeasures := getMeasures(indexes) | ||
medianValueIndex := resultMeasures.median | ||
medianValue := deck[medianValueIndex] | ||
|
||
// Build the hint based on the measures | ||
hint := &protocol.Hint{ | ||
Hint: medianValue, | ||
Advice: "", | ||
Acceptable: true, | ||
} | ||
|
||
if resultMeasures.maxDeviation > maxAcceptableMaximumDeviation { | ||
hint.Acceptable = false | ||
hint.RejectReason = maximumDeviationIsTooHigh | ||
} | ||
|
||
if resultMeasures.meanDeviation >= maxAcceptableMeanDeviation { | ||
hint.Acceptable = false | ||
hint.RejectReason = varietyOfVotesIsTooHigh | ||
} | ||
|
||
return hint, nil | ||
} | ||
|
||
func getVotesAsDeckIndexes(issueVotes protocol.IssueVotes, deck protocol.Deck) ([]int, error) { | ||
indexes := make([]int, 0, len(issueVotes)) | ||
for _, vote := range issueVotes { | ||
index := slices.Index(deck, vote.Value) | ||
if index < 0 { | ||
return nil, ErrVoteNotFoundInDeck | ||
} | ||
indexes = append(indexes, index) | ||
} | ||
return indexes, nil | ||
} | ||
|
||
// getMeasures returns: | ||
// - median value | ||
// - median absolute deviation | ||
// - maximum absolute deviation | ||
// - error if any occurred | ||
func getMeasures(values []int) hintMeasurements { | ||
r := hintMeasurements{} | ||
|
||
// median value | ||
r.median = median(values) | ||
|
||
// Maximum deviation | ||
r.maxDeviation = 0 | ||
for _, v := range values { | ||
deviation := math.Abs(float64(r.median) - float64(v)) | ||
r.maxDeviation = math.Max(r.maxDeviation, deviation) | ||
} | ||
|
||
// Average deviation | ||
sum := 0 | ||
for _, v := range values { | ||
sum += int(math.Abs(float64(r.median) - float64(v))) | ||
} | ||
r.meanDeviation = float64(sum) / float64(len(values)) | ||
|
||
return r | ||
|
||
} | ||
|
||
func median(values []int) int { | ||
if len(values) == 0 { | ||
return -1 | ||
} | ||
|
||
sort.Slice(values, func(i, j int) bool { | ||
return values[i] < values[j] | ||
}) | ||
center := len(values) / 2 | ||
return values[center] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
package game | ||
|
||
import ( | ||
"strings" | ||
"testing" | ||
|
||
"github.com/brianvoe/gofakeit/v6" | ||
"github.com/stretchr/testify/require" | ||
|
||
"github.com/six78/2-story-points-cli/pkg/protocol" | ||
) | ||
|
||
func TestMedian(t *testing.T) { | ||
// Odd number of votes | ||
votes := []int{1, 1, 1, 1, 2} | ||
hint := median(votes) | ||
require.Equal(t, 1, hint) | ||
|
||
// Even number of votes | ||
votes = []int{1, 1, 1, 2} | ||
hint = median(votes) | ||
require.Equal(t, 1, hint) | ||
|
||
// Test round up | ||
votes = []int{1, 1, 2, 2} | ||
hint = median(votes) | ||
require.Equal(t, 2, hint) | ||
|
||
// Empty list | ||
votes = []int{} | ||
hint = median(votes) | ||
require.Equal(t, -1, hint) | ||
} | ||
|
||
func TestHint(t *testing.T) { | ||
deck := protocol.Deck{"1", "2", "3", "5", "8", "13", "21"} | ||
t.Log("deck:", deck) | ||
|
||
type Case struct { | ||
values []protocol.VoteValue | ||
measurements hintMeasurements | ||
expectedHint protocol.Hint | ||
} | ||
|
||
// NOTE: Some test cases here are double check. | ||
// But the intention was also to see how the algorithm behaves for different scenarios. | ||
|
||
testCases := []Case{ | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.2, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "3", "8"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 2}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: maximumDeviationIsTooHigh}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "3", "13"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 3}, | ||
// Test: varietyOfVotesIsTooHigh takes precedence over maximumDeviationIsTooHigh | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "3", "21"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.8, maxDeviation: 4}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "5", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.4, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"3", "3", "3", "5", "8"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 0.6, maxDeviation: 2}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"2", "3", "3", "3", "3", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 7.0, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"2", "3", "3", "3", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 6.0, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"2", "3", "3", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 5.0, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: true, Hint: "3"}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"2", "3", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 4.0, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
{ | ||
values: []protocol.VoteValue{"2", "3", "5"}, | ||
measurements: hintMeasurements{median: 2, meanDeviation: 2 / 3.0, maxDeviation: 1}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "3", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
{ | ||
// This also tests round up median when even number of votes | ||
values: []protocol.VoteValue{"2", "3", "5", "8"}, | ||
measurements: hintMeasurements{median: 3, meanDeviation: 1, maxDeviation: 2}, | ||
expectedHint: protocol.Hint{Acceptable: false, Hint: "5", RejectReason: varietyOfVotesIsTooHigh}, | ||
}, | ||
} | ||
|
||
for _, tc := range testCases { | ||
name := voteValuesString(tc.values) | ||
t.Run(name, func(t *testing.T) { | ||
issueVotes := buildIssueVotes(tc.values) | ||
indexes, err := getVotesAsDeckIndexes(issueVotes, deck) | ||
require.NoError(t, err) | ||
|
||
// First, check the measures (private API) | ||
measures := getMeasures(indexes) | ||
require.Equal(t, tc.measurements, measures) | ||
|
||
// Now check the actual hint (public API) | ||
hint, err := GetResultHint(deck, issueVotes) | ||
require.NoError(t, err) | ||
require.Equal(t, tc.expectedHint, *hint) | ||
}) | ||
} | ||
} | ||
|
||
func TestInvalidVote(t *testing.T) { | ||
deck := protocol.Deck{"1", "2"} | ||
issueVotes := buildIssueVotes([]protocol.VoteValue{"1", "X"}) | ||
_, err := GetResultHint(deck, issueVotes) | ||
require.Error(t, err) | ||
require.Equal(t, ErrVoteNotFoundInDeck, err) | ||
} | ||
|
||
func voteValuesString(values []protocol.VoteValue) string { | ||
list := make([]string, len(values)) | ||
for i, v := range values { | ||
list[i] = string(v) | ||
} | ||
return strings.Join(list, ",") | ||
} | ||
|
||
func buildIssueVotes(votes []protocol.VoteValue) protocol.IssueVotes { | ||
issueVotes := make(protocol.IssueVotes) | ||
for _, v := range votes { | ||
playerID := protocol.PlayerID(gofakeit.UUID()) | ||
issueVotes[playerID] = protocol.VoteResult{Value: v} | ||
} | ||
return issueVotes | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
package protocol | ||
|
||
type Hint struct { | ||
// Acceptable shows if the voting for given issue can be considered as "acceptable". | ||
// It will be false if the variety of votes is too high. In this case Advice will contain | ||
// a suggestion to discuss and re-vote. | ||
Acceptable bool | ||
|
||
// RejectReason contains an explanation of why the vote is not acceptable. | ||
// When Acceptable is true, RejectReason is empty. | ||
RejectReason string | ||
|
||
// Hint is the recommended value for the issue. | ||
// It's guaranteed to be one of the values from the deck. | ||
Hint VoteValue | ||
|
||
// Advice is a text advice for the team about current vote. | ||
// It might contain players mentions in form "@<id>", where <id> a particular player ID. | ||
Advice string | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters