diff --git a/go/study/ch05_testify/README.md b/go/study/ch05_testify/README.md new file mode 100755 index 00000000..caf73f17 --- /dev/null +++ b/go/study/ch05_testify/README.md @@ -0,0 +1,207 @@ +# 初めての JSON + +## 予習してきてください + +1. 以下を調べてください(文章にすることを強く推奨) + - JSON とは何か + - JSON で使える型の種類 + - CSVとの違い +1. https://pkg.go.dev/encoding/json を読んでおいてください + +## :exclamation: セットアップ + +1. ターミナルあるいはコマンドプロンプトで `ch04_json` に移動 +2. `go mod init github.com/akm/second_tour_of_go/ch04_json` を実行 + - `github.com/akm` の部分は他の文字列に変更しても OK です。 + - Github 等で管理するのであれば、自分の環境に合わせて変更してください + +以下、実行形式のファイル名は `ch04_json` とします。 +該当しないサブコマンドが指定された場合にはヘルプを表示してください。 +何かエラーが起きた場合とヘルプを表示する際には、原因となるエラーを標準エラー出力に出力し、終了コードを1としてください。 + +## :question: example サブコマンド + +1. 以下のJSON形式の文字列を標準出力に出力するサブコマンド `example` を `Person` というstruct型を定義して作成してください + ```json + {"first_name":"Blake","last_name":"Serilda","birthday":"1989-07-10","age":33} + ``` +2. 上のJSON形式の文字列を読みやすく整形した文字列を標準出力に出力するようにサブコマンド `example` を変更してください + ```json + { + "first_name": "Blake", + "last_name": "Serilda", + "birthday": "1989-07-10", + "age": 33 + } + ``` +3. `Person` 型のスライスを使って以下のように出力するようにサブコマンド `example` を変更してください + ```json + [ + { + "first_name": "Blake", + "last_name": "Serilda", + "birthday": "1989-07-10", + "age": 33 + }, + { + "first_name": "Libbie", + "last_name": "Drisko", + "birthday": "1998-06-15", + "age": 24 + }, + { + "first_name": "Anestassia", + "last_name": "Truc", + "birthday": "1973-04-02", + "age": 48 + } + ] + ``` + +### ヒント + +- [json.Marshal](https://pkg.go.dev/encoding/json#Marshal) +- [json.MarshalIndent](https://pkg.go.dev/encoding/json#MarshalIndent) + + +## :question: summary サブコマンド + +1. 指定されたJSONファイル( 例えば [people.json](./people.json) ) を読み込んで、人数と平均年齢を出力するサブコマンド summary を追加してください。 + - 出力例 + ``` + 5 people, average age: 30 + ``` +2. 以下の場合にどのように振る舞うのかをしらべてください + 1. フィールド名がマッチしないJSONファイルを指定した場合 + 2. JSON形式じゃないファイルが指定した場合 + +### ヒント + +- [json.Unmarshal](https://pkg.go.dev/encoding/json#Unmarshal) + +## :question: スライス型の拡張 + +以下のような`People` 型を定義して、そのメソッド `AverageAge` で平均年齢を求めるようにサブコマンド summary を変更してください。 +Peopleの要素数が0の場合は0を返すものとします。このテストも作成してください。順番は初めてのテストで紹介したやり方を思い出してください。 + +```golang +type Person []*Person +``` + +## :question: estimate サブコマンド + +指定された商品JSONファイルと見積もりリクエストJSONファイルを読み込んで、見積もり結果JSONを標準出力に出力するサブコマンド `estimate` を作成してください。軽減税率の対象の商品の消費税は `8%` 、対象外の商品の消費税は `10%` とします。 +消費税は各商品毎に求め、端数は切り捨てください(問題を簡単にするため)。 +商品JSONに含まれない商品名が指定された場合は商品が見つからないという旨のエラーにしてください。 + +### 商品JSONファイル + +```json +{ + "Apple": {"unit_price": 200, "reduced_rate": false}, + "Orange": {"unit_price": 120, "reduced_rate": true}, + "Banana": {"unit_price": 250, "reduced_rate": true}, + "Kiwi Fruit": {"unit_price": 100, "reduced_rate": true}, + "Lemon": {"unit_price": 150, "reduced_rate": false} +} +``` + +商品名のキーに対して、unit_price(価格)とreduced_rate(軽減税率対象)のフィールドを持つオブジェクトを値とするマップです。 + +### 見積もりリクエストJSONファイル + +```json +{ + "client_name": "John Doe", + "items": [ + { + "product_name": "Apple", + "quantity": 3 + }, + { + "product_name": "Orange", + "quantity": 4 + }, + { + "product_name": "Banana", + "quantity": 2 + } + ] +} +``` + +client_name(顧客名)とitems(明細)を持つオブジェクトです。 +itemsの要素は、product_name(商品名)とquantity(数量)のフィールドを持つオブジェクトです。 + +### 見積もり結果JSON + +```json +{ + "client_name": "John Doe", + "estimated_at": "2022-09-18T16:27:20.467167+09:00", + "subtotal": 1580, + "tax": 138, + "total": 1718, + "items": [ + { + "product_name": "Apple", + "quantity": 3, + "subtotal": 600, + "tax_rate": 10, + "tax": 60 + }, + { + "product_name": "Orange", + "quantity": 4, + "subtotal": 480, + "tax_rate": 8, + "tax": 38 + }, + { + "product_name": "Banana", + "quantity": 2, + "subtotal": 500, + "tax_rate": 8, + "tax": 40 + } + ] +} +``` + +以下のフィールドを持つオブジェクトです。 + +- client_name(顧客名) +- estimated_at(見積もり日時) +- subtotal(小計、税抜) +- tax(消費税) +- total(合計金額) +- items(明細の配列) + +明細は以下のフィールドを持つオブジェクトです。 + +- product_name(商品名) +- quantity(数量) +- subtotal(小計、税抜) +- tax_rate(税率、%) +- tax(税額) + +### ヒント + +- 商品のデータについては、商品名をキー、(unit_priceとreduced_rateに相当するフィールドを持つ構造体)を値とする `map` を使うと作りやすいと思います +- 型の名前の候補 + - Product 製品 + - Estimate 見積もり + - Request リクエスト + - Response 結果 + - Item 要素 + - Map マップ + + +### チャレンジ + +1. どのような型を作るのかリストアップする +2. 何がどのメソッドを呼ぶのかを考える +3. 構造体やスライスの型を定義 +4. メソッドを仮実装 +5. テストを作成 +6. サブコマンド estimate を実装 diff --git a/go/study/ch05_testify/estimate.go b/go/study/ch05_testify/estimate.go new file mode 100755 index 00000000..da47f113 --- /dev/null +++ b/go/study/ch05_testify/estimate.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "time" +) + +// 商品の属性を表す型 +// 商品JSONファイルをUnmarshalして生成されるので、されるので、コンストラクタは不要 +type ProductAttrs struct { + UnitPrice int `json:"unit_price"` + ReducedRate bool `json:"reduced_rate"` +} + +func (m *ProductAttrs) TaxRate() int { + if m.ReducedRate { + return 8 + } else { + return 10 + } +} + +// 商品名とProductAttrsを関連付けるmapを拡張した型 +// 商品JSONファイルをUnmarshalして生成されるので、されるので、コンストラクタは不要 +type ProductMap map[string]*ProductAttrs + +func (m ProductMap) Get(product string) *ProductAttrs { + return m[product] +} + +func (m ProductMap) Calculate(req Request) (*Response, error) { + res := NewResponse(req.ClientName) + for _, item := range req.Items { + attrs := m.Get(item.ProductName) + if attrs == nil { + return nil, fmt.Errorf("unknown product: %v", item.ProductName) + } + res.Items = append(res.Items, NewResponseItem(item.ProductName, attrs, item.Quantity)) + } + res.Calculate() + return res, nil +} + +// 見積もりRequestを表す型 +// 見積もりRequestJSONファイルをUnmarshalして生成されるので、されるので、コンストラクタは不要 +type Request struct { + ClientName string `json:"client_name"` + Items []*RequestItem `json:"items"` +} + +// 見積もりの明細を表す型 +// 見積もりRequestJSONファイルをUnmarshalして生成されるので、されるので、コンストラクタは不要 +type RequestItem struct { + ProductName string `json:"product_name"` + Quantity int `json:"quantity"` +} + +// 見積もり結果を表す型 +type Response struct { + ClientName string `json:"client_name"` + EstimatedAt time.Time `json:"estimated_at"` + SubTotal int `json:"sub_total"` + Tax int `json:"tax"` + Total int `json:"total"` + Items []*ResponseItem `json:"items"` +} + +func NewResponse(clientName string) *Response { + return &Response{ClientName: clientName, EstimatedAt: Now()} +} + +func (m *Response) Calculate() { + m.SubTotal = 0 + m.Tax = 0 + for _, item := range m.Items { + m.SubTotal += item.SubTotal + m.Tax += item.Tax + } + m.Total = m.SubTotal + m.Tax +} + +// 見積もり結果の明細を表す型 +type ResponseItem struct { + ProductName string `json:"product_name"` + Quantity int `json:"quantity"` + SubTotal int `json:"sub_total"` + TaxRate int `json:"tax_rate"` + Tax int `json:"tax"` +} + +func NewResponseItem(productName string, attrs *ProductAttrs, quantity int) *ResponseItem { + subTotal := attrs.UnitPrice * quantity + taxRate := attrs.TaxRate() + return &ResponseItem{ + ProductName: productName, + Quantity: quantity, + SubTotal: subTotal, + TaxRate: taxRate, + Tax: subTotal * taxRate / 100, + } +} diff --git a/go/study/ch05_testify/estimate.md b/go/study/ch05_testify/estimate.md new file mode 100755 index 00000000..f7f99f63 --- /dev/null +++ b/go/study/ch05_testify/estimate.md @@ -0,0 +1,102 @@ +# estimate サブコマンド設計ログ + +## まず必要そうな型を列挙してみる + +```mermaid +classDiagram +ProductMap *-- ProductAttrs +ProductMap : get() ProductAttrs +ProductAttrs : UnitPrice int +ProductAttrs : ReducedRate bool +EstimateRequest *-- EstimateRequestItem +EstimateRequest : ClientName string +EstimateRequestItem : ProductName string +EstimateRequestItem : Quantity int +EstimateResponse *-- EstimateResponseItem +EstimateResponse : ClientName string +EstimateResponse : EstimatedAt time.Time +EstimateResponse : SubTotal int +EstimateResponse : Tax int +EstimateResponse : Total int +EstimateResponseItem : Quantity int +EstimateResponseItem : SubTotal int +EstimateResponseItem : TaxRate int +EstimateResponseItem : Tax int +``` + +ProductMap は `map[string]*ProductAttrs` で十分? + +### 名前が長いのでEstimateを除去する + +他に `Request` や `Response` を使うことは(今のところ)なさそうなので名前を短くしてしまう + +```mermaid +classDiagram +ProductMap *-- ProductAttrs +ProductMap : get() ProductAttrs +ProductAttrs : UnitPrice int +ProductAttrs : ReducedRate bool +Request *-- RequestItem +Request : ClientName string +RequestItem : ProductName string +RequestItem : Quantity int +Response *-- ResponseItem +Response : ClientName string +Response : EstimatedAt time.Time +Response : SubTotal int +Response : Tax int +Response : Total int +ResponseItem : Quantity int +ResponseItem : SubTotal int +ResponseItem : TaxRate int +ResponseItem : Tax int +``` + +## メソッドの呼び出しを決める + +### どうあるべきかを考える + +ProductMapとRequestからResponseを作るという処理なわけだが、RequestとResponseが対称的なものなので、 +引数にRequestを取ってResponseを返すというのがわかりやすそう。 +そうするとProductMapの使い方としては以下の2つがありそう。 + +1. グローバルな `Estimate` 関数に引数として `ProductMap` と `Request` を渡して `Response` を返す +2. `ProductMap` のメソッドとして `Request` を引数にとる `Estimate` が `Response` を返す + +どちらかと言えば 2 の方が責務が分かれるので良さそう。 + +### シーケンス図その1 + +```mermaid +sequenceDiagram +main->>ProductMap : new +main->>Request : new +main->>ProductMap : Estimate +ProductMap->>Response : new +ProductMap-->>main: Response +``` + +最初はどこでどのインスタンスを作るのかをざっくり考える。 +この時点ではまだResponseItemをどう作るとかまでは考えていない。 + +### シーケンス図その2 + +```mermaid +sequenceDiagram +main->>ProductMap : new +main->>Request : new +main->>ProductMap : Estimate +loop for each Request + ProductMap->>ProductMap : Get() ProductAttrs + ProductMap->>ResponseItem : new + ProductMap->>ResponseItem : Calculate(ProductAttrs) +end +ProductMap->>Response : new +ProductMap->>Response : Calculate +ProductMap-->>main: Response +``` + +どこで計算を行うのかを考えて決める。ここで細かいローカル変数などは考慮しない。 +主に型と型の間のやり取り(インスタンスの生成とメソッドの呼び出し)を考慮する。 +あくまで実装前のアイディア段階なので、実装したら変わってしまってもOKとする +(設計作業の結果を設計図として残してメンテナンス対象とするなら話は別)。 diff --git a/go/study/ch05_testify/estimate_test.go b/go/study/ch05_testify/estimate_test.go new file mode 100755 index 00000000..d25b1d1c --- /dev/null +++ b/go/study/ch05_testify/estimate_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestProductMap() ProductMap { + return ProductMap{ + "Apple": {UnitPrice: 200, ReducedRate: false}, + "Orange": {UnitPrice: 120, ReducedRate: true}, + "Banana": {UnitPrice: 250, ReducedRate: true}, + "Kiwi Fruit": {UnitPrice: 100, ReducedRate: true}, + "Lemon": {UnitPrice: 150, ReducedRate: false}, + } +} + +func TestProductMapGet(t *testing.T) { + m := newTestProductMap() + if r := m.Get("Apple"); assert.NotNil(t, r) { + assert.Equal(t, 200, r.UnitPrice) + assert.False(t, r.ReducedRate) + } + if r := m.Get("Banana"); assert.NotNil(t, r) { + assert.Equal(t, 250, r.UnitPrice) + assert.True(t, r.ReducedRate) + } + if r := m.Get("Grape"); assert.Nil(t, r) { + assert.Nil(t, r) + } +} + +func TestProductMapCalculate(t *testing.T) { + t.Run("basic pattern", func(t *testing.T) { + now := time.Now() + NowFunc = func() time.Time { return now } + defer func() { NowFunc = time.Now }() + + m := newTestProductMap() + res, err := m.Calculate(Request{ + ClientName: "John Smith", + Items: []*RequestItem{ + {ProductName: "Apple", Quantity: 2}, + {ProductName: "Orange", Quantity: 3}, + {ProductName: "Banana", Quantity: 4}, + }, + }) + require.NoError(t, err) + require.Equal(t, &Response{ + ClientName: "John Smith", + EstimatedAt: now, + SubTotal: 2*200 + 3*120 + 4*250, + Tax: 2*200*10/100 + 3*120*8/100 + 4*250*8/100, + Total: 2*200 + 3*120 + 4*250 + 2*200*10/100 + 3*120*8/100 + 4*250*8/100, + Items: []*ResponseItem{ + {ProductName: "Apple", Quantity: 2, SubTotal: 2 * 200, TaxRate: 10, Tax: 2 * 200 * 10 / 100}, + {ProductName: "Orange", Quantity: 3, SubTotal: 3 * 120, TaxRate: 8, Tax: 3 * 120 * 8 / 100}, + {ProductName: "Banana", Quantity: 4, SubTotal: 4 * 250, TaxRate: 8, Tax: 4 * 250 * 8 / 100}, + }, + }, res) + }) + + t.Run("including unknown product", func(t *testing.T) { + m := newTestProductMap() + _, err := m.Calculate(Request{ + ClientName: "John Smith", + Items: []*RequestItem{ + {ProductName: "Apple", Quantity: 2}, + {ProductName: "Grape", Quantity: 3}, + {ProductName: "Banana", Quantity: 4}, + }, + }) + require.Error(t, err) + }) +} + +func TestNewResponseItem(t *testing.T) { + m := newTestProductMap() + assert.Equal(t, + NewResponseItem("Apple", m.Get("Apple"), 2), + &ResponseItem{ProductName: "Apple", Quantity: 2, SubTotal: 2 * 200, TaxRate: 10, Tax: 2 * 200 * 10 / 100}, + ) + assert.Equal(t, + NewResponseItem("Orange", m.Get("Orange"), 3), + &ResponseItem{ProductName: "Orange", Quantity: 3, SubTotal: 3 * 120, TaxRate: 8, Tax: 3 * 120 * 8 / 100}, + ) +} diff --git a/go/study/ch05_testify/go.mod b/go/study/ch05_testify/go.mod new file mode 100755 index 00000000..a15241b6 --- /dev/null +++ b/go/study/ch05_testify/go.mod @@ -0,0 +1,5 @@ +module github.com/tecyokomichi/WebAppLearning/go/base/ch05_testify + +go 1.19 + +require github.com/stretchr/testify v1.8.1 // indirect diff --git a/go/study/ch05_testify/main.go b/go/study/ch05_testify/main.go new file mode 100755 index 00000000..88775142 --- /dev/null +++ b/go/study/ch05_testify/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" +) + +type Person struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Birthday string `json:"birthday"` + Age int `json:"age"` +} + +type People []*Person + +func (p People) AverageAge() int { + num := len(p) + if num == 0 { + return 0 + } + s := 0 + for _, i := range p { + s += i.Age + } + return s / num +} + +func main() { + if len(os.Args) < 2 { + showHelp() + os.Exit(1) + } + switch os.Args[1] { + case "example": + people := []*Person{ + { + FirstName: "Blake", + LastName: "Serilda", + Birthday: "1989-07-10", + Age: 33, + }, + { + FirstName: "Libbie", + LastName: "Drisko", + Birthday: "1998-06-15", + Age: 24, + }, + { + FirstName: "Anestassia", + LastName: "Truc", + Birthday: "1973-04-02", + Age: 48, + }, + } + b, err := json.MarshalIndent(people, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + fmt.Println(string(b)) + case "summary": + var people People + if len(os.Args) < 3 { + showHelp() + os.Exit(1) + } + if err := readAndUnmarshal(os.Args[2], &people); err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + fmt.Printf("%d people, average age: %d\n", len(people), people.AverageAge()) + case "estimate": + if len(os.Args) < 4 { + showHelp() + os.Exit(1) + } + var productMap ProductMap + if err := readAndUnmarshal(os.Args[2], &productMap); err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + var request Request + if err := readAndUnmarshal(os.Args[3], &request); err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + res, err := productMap.Calculate(request) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + } + b, err := json.MarshalIndent(res, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + fmt.Println(string(b)) + default: + showHelp() + os.Exit(1) + } +} + +func readAndUnmarshal(path string, dest interface{}) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(b, dest) +} + +func showHelp() { + fmt.Printf("Usage:\n") + fmt.Printf(" %s example\n", os.Args[0]) + fmt.Printf(" Shows an example of JSON data\n") + fmt.Printf(" %s summary FILE\n", os.Args[0]) + fmt.Printf(" Shows summary of people from FILE\n") + fmt.Printf(" %s estimate PRODUCT_FILE REQUEST_FILE\n", os.Args[0]) + fmt.Printf(" Shows estimate for REQUEST_FILE with PRODUCT_FILE\n") +} diff --git a/go/study/ch05_testify/main_test.go b/go/study/ch05_testify/main_test.go new file mode 100755 index 00000000..d4ebeeb1 --- /dev/null +++ b/go/study/ch05_testify/main_test.go @@ -0,0 +1,41 @@ +package main + +import "testing" + +func TestPeopleAverageAge(t *testing.T) { + t.Run("0 people", func(t *testing.T) { + people := People{} + if people.AverageAge() != 0 { + t.Error("expected 0, got", people.AverageAge()) + } + }) + + t.Run("1 person", func(t *testing.T) { + age := 25 + people := People{ + { + FirstName: "Libbie", + LastName: "Drisko", + Birthday: "1998-06-15", + Age: age, + }, + } + actual := people.AverageAge() + if actual != age { + t.Errorf("expected %d, got %d", age, actual) + } + }) + + t.Run("3 person", func(t *testing.T) { + people := People{ + {Age: 10}, + {Age: 30}, + {Age: 50}, + } + expect := 30 + actual := people.AverageAge() + if actual != expect { + t.Errorf("expected %d, got %d", expect, actual) + } + }) +} diff --git a/go/study/ch05_testify/people.json b/go/study/ch05_testify/people.json new file mode 100755 index 00000000..f46a81d4 --- /dev/null +++ b/go/study/ch05_testify/people.json @@ -0,0 +1,32 @@ +[ + { + "first_name": "Blake", + "last_name": "Serilda", + "birthday": "1989-07-10", + "age": 33 + }, + { + "first_name": "Libbie", + "last_name": "Drisko", + "birthday": "1998-06-15", + "age": 24 + }, + { + "first_name": "Anestassia", + "last_name": "Truc", + "birthday": "1973-04-02", + "age": 48 + }, + { + "first_name": "Cyndie", + "last_name": "Syd", + "birthday": "1985-07-12", + "age": 37 + }, + { + "first_name": "Joelly", + "last_name": "Honoria", + "birthday": "2012-01-31", + "age": 10 + } +] diff --git a/go/study/ch05_testify/product_map.json b/go/study/ch05_testify/product_map.json new file mode 100755 index 00000000..6f614d9b --- /dev/null +++ b/go/study/ch05_testify/product_map.json @@ -0,0 +1,7 @@ +{ + "Apple": { "unit_price": 200, "reduced_rate": false }, + "Orange": { "unit_price": 120, "reduced_rate": true }, + "Banana": { "unit_price": 250, "reduced_rate": true }, + "Kiwi Fruit": { "unit_price": 100, "reduced_rate": true }, + "Lemon": { "unit_price": 150, "reduced_rate": false } +} diff --git a/go/study/ch05_testify/request.json b/go/study/ch05_testify/request.json new file mode 100755 index 00000000..03c354e7 --- /dev/null +++ b/go/study/ch05_testify/request.json @@ -0,0 +1,17 @@ +{ + "client_name": "John Doe", + "items": [ + { + "product_name": "Apple", + "quantity": 3 + }, + { + "product_name": "Orange", + "quantity": 4 + }, + { + "product_name": "Banana", + "quantity": 2 + } + ] +} diff --git a/go/study/ch05_testify/time.go b/go/study/ch05_testify/time.go new file mode 100644 index 00000000..f652e86e --- /dev/null +++ b/go/study/ch05_testify/time.go @@ -0,0 +1,9 @@ +package main + +import "time" + +var NowFunc = time.Now + +func Now() time.Time { + return NowFunc() +}