テスト駆動開発実践:ゆめみさんの模試を題材にして
ToDo1
まずは問題設定から読み解いたToDoを書き出してみる
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
Test01
csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
テストとして書いてみる。バッファリングする都合上、この部分を別関数として切り出せないと思うので(defer file.Close()を別関数でした瞬間、読み取り結果がnilになる)、テストコードとして作った処理を後から使い回す気がする。
読み取ったデータがどのようなデータ構造になるかを知りたいのもあって、ここを一番に実施する。
package ranking
func TestReadCsv(t *testing.T) {
path := "../testdata/input.csv"
parsed, err := filepath.Abs(path)
if err != nil {
t.Error("could not parse filepath")
}
f, err := os.Open(parsed)
if err != nil {
t.Error("could not open file")
}
defer f.Close()
reader := csv.NewReader(f)
for {
record, err := reader.Read()
if err == io.EOF {
return
}
if err != nil {
t.Error("could not read csv line")
}
fmt.Println(record)
}
}
どんな読み取り結果か確認したいので、お手軽にfmt.Println
している。標準出力に表示させるため-v
オプションをつけてテストを実行する。
=== RUN TestReadCsv
[create_timestamp player_id score]
[2021/01/01 12:00 player0001 12345]
[2021/01/02 13:00 player0002 10000]
[2021/01/03 12:00 player0021 100]
[2021/01/04 12:10 player0031 300]
[2021/01/05 12:00 player0041 300]
[2021/01/06 12:00 player0001 10000]
--- PASS: TestReadCsv (0.00s)
ふむふむ、全て文字列として、各行が[]string
で返ってくるっぽい。
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
一つ済んだ
ちなみにフォルダ構成は下記にするつもりで進めている。
ranking/
|- ranking.go
|- ranking_test.go
main.go
go.mod
go.sum
次のテストに進もう。
読込んだcsvのヘッダが適切か確認する(行数、項目名)
- バリデーションしなさいとは問題に書いていないが、実際業務上として渡されたファイルフォーマットのチェックは必要だと思うので実装する。
- 気にするのは項目名と項目の数ぐらいにしよう。
- 本当はテストケースを構造体にして管理した方が良いのは知ってはいるけど、まずはちゃっちゃか書く。
func TestValidateHeader(t *testing.T) {
validHeader := []string{"create_timestamp", "player_id", "score"}
if !ValidateHeader(validHeader) {
t.Error("validation fails")
}
invalidHeader := []string{"create_timestamp ", "player_id ", "score "}
if ValidateHeader(invalidHeader) {
t.Error("validation fails")
}
invalidHeader = []string{"create_timestamp", "player_id"}
if ValidateHeader(invalidHeader) {
t.Error("validation fails")
}
}
これに対する実装を行う。
ここからはまずコンパイルを通す->エラーを消す->リファクタリングするというループを繰り返していくけど、ここに記載するのは少し端折る予定・・・。
まずは所定の関数がなくてコンパイルが通らないので空で宣言。
func ValidateHeader(header []string) bool {
return true
}
コンパイルは通るけど予想通りエラーになる。
まじめに実装する。
func ValidateHeader(header []string) bool {
expected := []string{"create_timestamp", "player_id", "score"}
if len(header) != 3 {
return false
}
for i, h := range expected {
if header[i] != h {
return false
}
}
return true
}
うん、パスした。よかろう。
また一つ済にできた。
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
次に、このToDoに取り組む。
あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
ToDoを書き出した時点で、頭の中で済ませてしまっているが、本当に必要な平均点を得るために、合計点数とカウント回数をまとめておきたいというもの。
おっと、csvを読み込んだ結果は全て文字列になっている。
合計を計算するには数値に変換する必要がある。。。これもToDoに追加して、先に済ませてしまわなければならない。
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
func TestParseRecord(t *testing.T) {
r := []string{"2021/01/01 12:00", "player0001", "12345"}
parsed, err := ParseRecord(r)
if err != nil {
t.Error("could not parse a valid input")
}
if parsed.player != r[1] {
t.Error("player: not expected value")
}
if parsed.score != 12345 {
t.Error("score: not expected value")
}
// scoreは型変換が入るのでチェックする
if fmt.Sprintf("%T", parsed.score) != "int" {
t.Error("score type: not expected type")
}
}
この時点で設計判断を下している部分がある。
csvを読み込んで得た各行を、player
とscore
というフィールドを持った構造体にパースすることにしている。
なんでそうしたかと聞かれると困るが、何列目が何を表すかという思考を引きずりたくなく、この後直感的にコーディングできるように構造体にすることにしたのだと思う。
お察しの通りまずはコンパイルが通らない。ここに全てのステップを書くと冗長になるので、テストがパスした段階のコードだけ記そう。
type ParsedRecord struct {
player string
score int
}
// input: []string{date, player, score}
func ParseRecord(r []string) (*ParsedRecord, error) {
if len(r) != 3 {
return nil, errors.New("invalid input")
}
score, err := strconv.Atoi(r[2])
if err != nil {
return nil, err
}
// scoreの最大値超え確認は必要?
return &ParsedRecord{r[1], score}, nil
}
スコアの上限がint
型の上限を超えないかモヤモヤするのでコメントだけ残しておいた。
将来的に超ハイスコアプレイヤーが登場するようになったら直してもらおう。
うん、また一つチェックをつけることができた。前進している。
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
もともとやりたかった合計得点と投稿回数の取得に移ろう。
create_timestamp,player_id,score
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,200
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player0001,10000
func TestPlayerSum(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
counter := NewCounter()
counter.Count(data)
sumCounter := counter.SumCounter
if player1 := sumCounter["player0001"]; player1.sum != 22345 {
t.Errorf("expected 22345: got %d\n", player1.sum)
}
if player1 := sumCounter["player0002"]; player1.sum != 10000 {
t.Errorf("expected 10000: got %d\n", player1.sum)
}
}
このテストを書く間に、次のような設計判断をしている。
- プレイヤーごとに合計得点と加算回数を整理したいので、プレイヤー名をキーとするマップを作ることにした
- 上記マップの値には、合計得点と加算回数をフィールドとする構造体(
SumCounter
)を使う - ゆくゆくは平均点も計算する必要がある。そこで
Counter
という構造体を作り、その中に上記のマップと今後計算するだろう平均点を収めるようにしよう。
最終的な実装結果がこちら
type PlayerSum struct {
sum int
count int
}
type Counter struct {
SumCounter map[string]PlayerSum
}
func NewCounter() *Counter {
counter := new(Counter)
counter.SumCounter = make(map[string]PlayerSum)
return counter
}
func (c *Counter) Count(r *csv.Reader) {
if r == nil {
return
}
header, err := r.Read()
if err != nil {
return
}
if !ValidateHeader(header) {
return
}
for {
record, err := r.Read()
if err == io.EOF {
break
}
// ある行が無効だったら飛ばして処理を継続する
if err != nil {
continue
}
parsed, err := ParseRecord(record)
if err != nil {
continue
}
playerSum, exist := c.SumCounter[parsed.player]
if !exist {
c.SumCounter[parsed.player] = PlayerSum{parsed.score, 1}
} else {
c.SumCounter[parsed.player] = PlayerSum{
playerSum.sum + parsed.score,
playerSum.count + 1,
}
}
}
}
各種命名がモヤっとしている。テストが通ったら直していこう。
よしよしパスした
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
- モヤっとしていた各種命名を修正した。 (どうでも良い話だが、合計得点を表示するboardのようなメタファーに基づいてみた)
- 処理が長くなっていたところを切り出した。
テストも通っている。よしよし。
func TestSumCounter(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
rank := NewRanking()
rank.Aggregate(data)
sumBoard := rank.sumBoard
tests := map[string]int{
"player0001": 22345,
"player0002": 10000,
}
for player, sum := range tests {
if sumCounter := sumBoard[player]; sumCounter.sum != sum {
t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
}
}
}
type SumCounter struct {
sum int
count int
}
type Ranking struct {
sumBoard map[string]SumCounter
}
func NewRanking() *Ranking {
rank := new(Ranking)
rank.sumBoard = make(map[string]SumCounter)
return rank
}
func (r *Ranking) Aggregate(records *csv.Reader) {
if records == nil {
return
}
header, err := records.Read()
if err != nil {
return
}
if !ValidateHeader(header) {
return
}
for {
record, err := records.Read()
if err == io.EOF {
break
}
// ある行が無効だったら飛ばして処理を継続する
if err != nil {
continue
}
r.updateSum(record)
}
}
func (r *Ranking) updateSum(record []string) {
parsed, err := ParseRecord(record)
if err != nil {
return
}
current, exist := r.sumBoard[parsed.player]
if !exist {
r.sumBoard[parsed.player] = SumCounter{parsed.score, 1}
} else {
r.sumBoard[parsed.player] = SumCounter{
current.sum + parsed.score,
current.count + 1,
}
}
}
↑ から、プレイヤーidと平均得点のマップを計算する
いよいよ本丸に取り掛かろう。
Ranking.Aggregate
というメソッドは平均点の取得までを見据えているので、この中にテストを追加する。
(本当はテストを分けてあげた方が良い気がするが、ファイルの読み込み部分など重複してしまうので追記する形にした)
func TestAggregate(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
rank := NewRanking()
rank.Aggregate(data)
sumBoard := rank.sumBoard
tests := map[string]int{
"player0001": 22345,
"player0002": 10000,
}
for player, sum := range tests {
if sumCounter := sumBoard[player]; sumCounter.sum != sum {
t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
}
}
+ meanBoard := rank.meanBoard
+ meanTests := map[string]float32{
+ "player0001": 22345.0 / 2,
+ "player0002": 10000,
+ }
+ for player, mean := range meanTests {
+ if m := meanBoard[player]; m != mean {
+ t.Errorf("expected %f, got %f\n", mean, m)
+ }
+ }
}
このまとめを作っている間に気が付きましたが、平均点は四捨五入で整数にする指定がありました。
公開した最終ソースコードは修正済みですが、本記事の中ではこのままでいきます。
いざ実装に移ろうとしたところで躓いてしまった。
検索の便利さからmap[player]mean
という返り値を想定したテストを書いた。
しかしmap
では並び順が保証されないので、せっかくこのmap
を作っても、後から平均得点順に並び替える時に別にスライスを作り直す必要が出てくるだろうことがわかってしまった。
ここは一度テストを見直そうと思う。
プレイヤー名と平均点をフィールドとする構造体を作り、そのスライスを作ることにしよう。
func TestAggregate(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
rank := NewRanking()
rank.Aggregate(data)
sumBoard := rank.sumBoard
tests := map[string]int{
"player0001": 22345,
"player0002": 10000,
}
for player, sum := range tests {
if sumCounter := sumBoard[player]; sumCounter.sum != sum {
t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
}
}
meanBoard := rank.meanBoard
+ meanTests := []PlayerMean{
+ {"player0001", 22345.0 / 2},
+ {"player0002", 10000},
}
+ hits := 0
+ for _, expected := range meanTests {
+ for _, calculated := range meanBoard {
+ if expected.player == calculated.player {
+ hits++
+ if expected.mean != calculated.mean {
+ t.Errorf("expected %f, got %f\n", expected.mean, calculated.mean)
+ }
+ }
+ }
+ }
+ if hits != len(meanTests) {
+ t.Errorf("player num is insufficient")
}
}
テストの成功条件は複雑になってしまったが、この後のことを考えてテスト自体をリファインできた。
この時点ではmeanBoard
の中身は点数順にソートされていることを期待していないので、確認方法が煩雑な感じ。
いざ実装
+type PlayerMean struct {
+ player string
+ mean float32
+}
type Ranking struct {
sumBoard map[string]SumCounter
+ meanBoard []*PlayerMean
}
func NewRanking() *Ranking {
+ cap_player := 1000
rank := new(Ranking)
rank.sumBoard = make(map[string]SumCounter)
+ rank.meanBoard = make([]*PlayerMean, 0, cap_player)
return rank
}
func (r *Ranking) Aggregate(records *csv.Reader) {
if records == nil {
return
}
header, err := records.Read()
if err != nil {
return
}
if !ValidateHeader(header) {
return
}
for {
record, err := records.Read()
if err == io.EOF {
break
}
// ある行が無効だったら飛ばして処理を継続する
if err != nil {
continue
}
r.updateSum(record)
}
+ r.updateMean()
}
+func (r *Ranking) updateMean() {
+ for player, sumCounter := range r.sumBoard {
+ cnt := sumCounter.count
+ if cnt == 0 {
+ continue
+ }
+ mean := float32(sumCounter.sum) / float32(cnt)
+ r.meanBoard = append(r.meanBoard, &PlayerMean{player, mean})
+ }
+}
[]*PlayerMean
のキャパシティーは正直適当。問題文に一万人以上にはならないと書かれていたので、適当に1/10にしておいた。あんまりグローバルに定義してもなんのことかわからないし、コンストラクタの中だけに隠しておこう…。
よしパス!
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
最初から複数プレイヤーに拡張してしまってあったのでテストをクローズした
次は平均得点による並び替え。いよいよゴールまで後少しという感じ。
func TestSortMeanBoard(t *testing.T) {
test := []*PlayerMean{
{"player2", 100},
{"player1", 100},
{"player3", 200},
{"player4", 50},
}
// 要求にないので同点の時にプレイヤー名の五十音で並び替えるなどは実装していない
expected := []*PlayerMean{
{"player3", 200},
{"player2", 100},
{"player1", 100},
{"player4", 50},
}
// this method override order
sortMeanBoard(test)
for i, player := range expected {
if test[i].player != player.player {
t.Errorf("unexpected order at index of %d", i)
}
}
}
仕様になかったので、点数が同点だった時にどう並び替えるかは実装していない。
この並び替えの実装は正直悩んだ…。sort
パッケージのgodocを見たらそのまんまの例があったので使わせてもらった。
func sortMeanBoard(board []*PlayerMean) {
// 平均点で並び替え
sort.Slice(board, func(i, j int) bool {
return board[i].mean > board[j].mean
})
}
ここまでで平均得点降順に並んだプレイヤー一覧が準備できている。あとやることは順位を付与して、それを出力してあげるだけだ。
…ところがどっこい、この順位付与が意外とめんどくさい。同点の時の処置として、1位の人が二人いたら、1, 1, 3のような順位付けをしないといけない。今の所の最高得点を覚えておいて、もし同じだったら次点の人につける順位を考えておいてあげないとならない。
テストがどんどん長くなっていくな…という自分の心の声を聞いた時、あ、どこかでロジックを分離できるんじゃないか?と思いついた。
そこから思考を巡らせ、次のように方針転換を行うことにした。
- プレイヤーの得点の集計と並び替えまでをおこなう
Recorder
構造体を設ける - ↑ を受け取り、順位付けと発表までを行う
Judege
構造体を設ける
得点記録係と、最終的な順位を決める審判のようなメタファーに基づくロジックである。
この思考に基づくと、ここまで実装してきた内容はRecorder
のメソッドと、その他一般的な関数となる。
順位付けの責務を負うJudge
とそのメソッドはここから作るものだ。
ボリュームが膨らんできたこともあり、実装側のコードはファイルを分割する必要もありそうだ。
かなり大掛かりなリファクタリングになるが、こちらには足場となるテストがある。テストと実装を慎重にいじっていけば怖いことはないだろう。
リファクタリング適用後
func TestValidateHeader(t *testing.T) {
validHeader := []string{"create_timestamp", "player_id", "score"}
if !ValidateHeader(validHeader) {
t.Error("validation fails")
}
invalidHeader := []string{"create_timestamp ", "player_id ", "score "}
if ValidateHeader(invalidHeader) {
t.Error("validation fails")
}
invalidHeader = []string{"create_timestamp", "player_id"}
if ValidateHeader(invalidHeader) {
t.Error("validation fails")
}
}
func TestParseRecord(t *testing.T) {
r := []string{"2021/01/01 12:00", "player0001", "12345"}
parsed, err := ParseRecord(r)
if err != nil {
t.Error("could not parse a valid input")
}
if parsed.player != r[1] {
t.Error("player: not expected value")
}
if parsed.score != 12345 {
t.Error("score: not expected value")
}
// scoreは型変換が入るのでチェックする
if fmt.Sprintf("%T", parsed.score) != "int" {
t.Error("score type: not expected type")
}
}
func TestAggregate(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
recorder := NewRecorder()
recorder.Aggregate(data)
sumBoard := recorder.sumBoard
tests := map[string]int{
"player0001": 22345,
"player0002": 10000,
}
for player, sum := range tests {
if sumCounter := sumBoard[player]; sumCounter.sum != sum {
t.Errorf("expected %d: got %d\n", sum, sumCounter.sum)
}
}
meanBoard := recorder.meanBoard
meanTests := []PlayerMean{
{"player0001", 22345.0 / 2},
{"player0002", 10000},
}
hits := 0
for _, expected := range meanTests {
for _, calculated := range meanBoard {
if expected.player == calculated.player {
hits++
if expected.mean != calculated.mean {
t.Errorf("expected %f, got %f\n", expected.mean, calculated.mean)
}
}
}
}
if hits != len(meanTests) {
t.Errorf("player num is insufficient")
}
}
func TestSortMeanBoard(t *testing.T) {
test := []*PlayerMean{
{"player2", 100},
{"player1", 100},
{"player3", 200},
{"player4", 50},
}
// 要求にないので同点の時にプレイヤー名の五十音で並び替えるなどは実装していない
expected := []*PlayerMean{
{"player3", 200},
{"player2", 100},
{"player1", 100},
{"player4", 50},
}
// this method override order
sortMeanBoard(test)
for i, player := range expected {
if test[i].player != player.player {
t.Errorf("unexpected order at index of %d", i)
}
}
}
package ranking
import (
"errors"
"sort"
"strconv"
)
type ParsedRecord struct {
player string
score int
}
func ValidateHeader(header []string) bool {
expected := []string{"create_timestamp", "player_id", "score"}
if len(header) != 3 {
return false
}
for i, h := range expected {
if header[i] != h {
return false
}
}
return true
}
// input: []string{date, player, score}
func ParseRecord(r []string) (*ParsedRecord, error) {
if len(r) != 3 {
return nil, errors.New("invalid input")
}
score, err := strconv.Atoi(r[2])
if err != nil {
return nil, err
}
// scoreの最大値超え確認は必要?
return &ParsedRecord{r[1], score}, nil
}
func sortMeanBoard(board []*PlayerMean) {
// 平均点で並び替え
sort.Slice(board, func(i, j int) bool {
return board[i].mean > board[j].mean
})
}
package ranking
import (
"encoding/csv"
"io"
)
type SumCounter struct {
sum int
count int
}
type PlayerMean struct {
player string
mean float32
}
type Recorder struct {
sumBoard map[string]SumCounter
meanBoard []*PlayerMean
}
func NewRecorder() *Recorder {
cap_player := 1000
recorder := new(Recorder)
recorder.sumBoard = make(map[string]SumCounter)
recorder.meanBoard = make([]*PlayerMean, 0, cap_player)
return recorder
}
func (r *Recorder) Aggregate(records *csv.Reader) {
if records == nil {
return
}
header, err := records.Read()
if err != nil {
return
}
if !ValidateHeader(header) {
return
}
for {
record, err := records.Read()
if err == io.EOF {
break
}
// ある行が無効だったら飛ばして処理を継続する
if err != nil {
continue
}
r.updateSum(record)
}
r.updateMean()
}
func (r *Recorder) updateSum(record []string) {
parsed, err := ParseRecord(record)
if err != nil {
return
}
current, exist := r.sumBoard[parsed.player]
if !exist {
r.sumBoard[parsed.player] = SumCounter{parsed.score, 1}
} else {
r.sumBoard[parsed.player] = SumCounter{
current.sum + parsed.score,
current.count + 1,
}
}
}
func (r *Recorder) updateMean() {
for player, sumCounter := range r.sumBoard {
cnt := sumCounter.count
if cnt == 0 {
continue
}
mean := float32(sumCounter.sum) / float32(cnt)
r.meanBoard = append(r.meanBoard, &PlayerMean{player, mean})
}
}
いよいよ順位の読み上げである。
先ほどのように責務を検討したので、順位付けと出力(読み上げ)は同時に行う想定になっている。
はて、最終的には標準出力に出力するが、それではテストができないではないか。
とするとJudge
は出力先を切り替える能力が必要そうだ。
それをテストを通じて表現してみよう。
func TestJudgeOutput(t *testing.T) {
path, _ := filepath.Abs("../testdata/input.csv")
f, err := os.Open(path)
if err != nil {
t.Error("bad test: could not load test input")
}
defer f.Close()
data := csv.NewReader(f)
recorder := NewRecorder()
recorder.Aggregate(data)
judge := NewJudge(recorder)
writer := new(bytes.Buffer)
judge.SetWriter(writer)
judge.Output()
expectedSlice := []string{
"1,player0001,11172.50",
"2,player0002,10000.00",
"3,player0031,300.00",
"3,player0041,300.00",
"5,player0021,100.00",
}
s := writer.String()
s = strings.TrimRight(s, "\n") // trim last newline
ss := strings.Split(s, "\n")
for _, expected := range expectedSlice {
if !isin(expected, ss) {
t.Errorf("could not find %s\n", expected)
}
}
}
func isin(target string, words []string) bool {
for _, w := range words {
if target == w {
return true
}
}
return false
}
よし実装だ。
package ranking
import (
"fmt"
"io"
"os"
)
const DISPLAY_MAX_RANK = 10
type Judge struct {
last float32
rank int
skip int
record *Recorder
writer io.Writer
}
func NewJudge(r *Recorder) *Judge {
judge := new(Judge)
judge.last = 0.0
judge.rank = 0
judge.skip = 0
judge.record = r
return judge
}
func (j *Judge) SetWriter(w io.Writer) {
j.writer = w
}
func (j *Judge) Output() {
w := j.writer
if j.writer == nil {
w = os.Stdout
}
for _, playerMean := range j.record.meanBoard {
// 同点を考慮して例えば 1, 1, 3 のような順位を表示するための処理
if j.last != playerMean.mean {
j.last = playerMean.mean
j.rank = j.rank + j.skip + 1
j.skip = 0
} else {
j.skip++
}
if j.rank > DISPLAY_MAX_RANK {
break
}
fmt.Fprintf(w, "%d,%s,%.2f\n", j.rank, playerMean.player, playerMean.mean)
}
}
出力先の切り替えはfmt.Fprintf
の力を借りた。
ちょっとごちゃごちゃしているが、重複があるわけでもなく、テストもパスするのでこれでOKだ。
- csvファイルを読み込む。ファイルが大きい可能性があるのでバッファで読み込む。
- 入力データのバリデーションはどこまで必要?⇒今回は指定がないので無視
- 読込んだcsvのヘッダが適切か確認する(行数、項目名)
- csvのヘッダ以外の列をパースする(後工程を見据えた型変換を入れる)
- あるプレイヤーについて合計得点と出現回数をインクリメントカウントする
- ↑ から、プレイヤーidと平均得点のマップを計算する
- 複数のプレイヤーに拡張する
- 平均得点順に並び変える
- 順位を算出する。平均得点が同じ場合は同着とし、その分順位をインクリメントする。
- 順位を出力する
All done !
最後にmain.go
の中から使いやすいように適当に統合した関数を作っておく。
func TestIntegration(t *testing.T) {
es := []string{
"1,player0001,11563.33",
"2,player0002,10000.00",
"3,player001,1000.00",
"3,player002,1000.00",
"5,player0031,300.00",
"5,player0041,300.00",
"7,player0021,100.00",
"7,player01,100.00",
"7,player02,100.00",
"10,player031,30.00",
"10,player041,30.00",
}
filename := "../testdata/large.csv"
writer := new(bytes.Buffer)
err := GetRank(filename, writer)
if err != nil {
t.Errorf("Got error: %s\n", err.Error())
}
// note: return values' order could not be guaranteed
s := writer.String()
s = strings.TrimRight(s, "\n") // trim last newline
ss := strings.Split(s, "\n")
for _, expected := range es {
if !isin(expected, ss) {
t.Errorf("could not find %s\n", expected)
}
}
}
これでOK
func GetRank(filename string, w io.Writer) error {
path, err := filepath.Abs(filename)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
data := csv.NewReader(f)
recorder := NewRecorder()
recorder.Aggregate(data)
judge := NewJudge(recorder)
judge.SetWriter(w)
judge.Output()
return nil
}
create_timestamp,player_id,score
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,300
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player0001,10000
2021/01/01 12:00,player0001,12345
2021/01/02 13:00,player0002,10000
2021/01/03 12:00,player0021,100
2021/01/04 12:10,player0031,300
2021/01/05 12:00,player0041,300
2021/01/06 12:00,player001,1000
2021/01/02 13:00,player002,1000
2021/01/03 12:00,player021,10
2021/01/04 12:10,player031,30
2021/01/05 12:00,player041,30
2021/01/06 12:00,player001,1000
2021/01/02 13:00,player002,1000
2021/01/03 12:00,player021,10
2021/01/04 12:10,player031,30
2021/01/05 12:00,player041,30
2021/01/06 12:00,player01,100
2021/01/05 12:00,player41,3
2021/01/06 12:00,player01,100
2021/01/02 13:00,player02,100
2021/01/03 12:00,player21,1
2021/01/04 12:10,player31,3
2021/01/05 12:00,player41,3
2021/01/06 12:00,player01,100
最後に実行プログラムを書く。ここまでのテストが通っているので、これは処理が実行できたかどうかだけで良し悪しがわかる。
package main
import (
"flag"
"log"
"os"
"github.com/tenkoh/yumemi-moshi/ranking"
)
func main() {
flag.Parse()
args := flag.Args()
if len(args) != 1 {
log.Fatal("invalid command.")
}
filepath := args[0]
w := os.Stdout
err := ranking.GetRank(filepath, w)
if err != nil {
log.Fatal("operation failed")
}
}
お疲れ様でした!