diff --git a/kadai2/hiroya-w/.gitignore b/kadai2/hiroya-w/.gitignore new file mode 100644 index 00000000..5efb5c8a --- /dev/null +++ b/kadai2/hiroya-w/.gitignore @@ -0,0 +1,5 @@ +# binaries +bin/ + +# coverage outputs +coverage.out diff --git a/kadai2/hiroya-w/Makefile b/kadai2/hiroya-w/Makefile new file mode 100644 index 00000000..62575dad --- /dev/null +++ b/kadai2/hiroya-w/Makefile @@ -0,0 +1,57 @@ +NAME := imgconv +VERSION := $(gobump show -r) +REVISION := $(shell git rev-parse --short HEAD) +LDFLAGS := "-X main.revision=$(REVISION)" + +## Install dependencies +.PHONY: deps +deps: + go get -v -d + +## Setup +.PHONY: devel-deps +devel-deps: devel-deps + go install honnef.co/go/tools/cmd/staticcheck@latest + go install github.com/kisielk/errcheck@latest + go install github.com/x-motemen/gobump/cmd/gobump@latest + go install github.com/Songmu/make2help/cmd/make2help@latest + +## Run tests +.PHONY: test +test: deps + go test -v -race -cover -coverprofile=coverage.out ./... + +## Generate testdatas +.PHONY: test-deps +test-deps: + cd _tools && sh gen_testdata.sh ../testdata + +## Show coverage +.PHONY: cover +cover: + go tool cover -html=coverage.out + +## Lint +.PHONY: lint +lint: deps + go vet ./... + staticcheck ./... + errcheck -ignore 'fmt:[FS]?[Pp]rint*' ./... + +## build binaries +bin/%: cmd/%/main.go deps + go build -ldflags $(LDFLAGS) -o $@ $< + +## build binary +.PHONY: build +build: bin/imgconv + +## show go documention +.PHONY: doc +doc: + godoc -http=:8080 + +## Show help +.PHONY: help +help: + @make2help $(MAKEFILE_LIST) diff --git a/kadai2/hiroya-w/README.md b/kadai2/hiroya-w/README.md new file mode 100644 index 00000000..4bddc49b --- /dev/null +++ b/kadai2/hiroya-w/README.md @@ -0,0 +1,64 @@ +# 課題2 + +## テストを書いてみよう + +- [x] テストのしやすさを考えてリファクタリングしてみる +- [x] テストのカバレッジを取ってみる +- [x] テーブル駆動テストを行う +- [ ] テストヘルパーを作ってみる + +今回のテストで、どの部分にテストヘルパーを利用出来るのかがわからなかった。他の方のPRを参考に眺めてみようと思う。 +Goでオブジェクト指向をしようとしてハマるやつをやりかけてしまっているように感じたので、もう少しGoらしい書き方を勉強してもいいなと思った。 + +## usage + +``` +.bin/imgconv -h +Usage of .bin/imgconv: + -input-type string + input type[jpg|jpeg|png|gif] (default "jpg") + -output-type string + output type[jpg|jpeg|png|gif] (default "png") +``` + +基本的なコマンドは `Makefile` で利用できます。 + +### build + +ビルドに必要なパッケージを取得します。 + +``` +make devel-deps +``` + +ビルドすると `bin` フォルダに `imgconv` のバイナリが生成されます。 + +``` +make build +``` + +### test + +`testdata` にテスト用の画像を生成します。 +その後、 `make test` でテストを実行します。 + +``` +make test-deps +make test +``` + +### coverage + +テストの実行後、カバレッジを表示します。 + +``` +make cover +``` + +### document + +ドキュメントを表示します。 + +``` +make doc +``` diff --git a/kadai2/hiroya-w/_tools/gen_testdata.sh b/kadai2/hiroya-w/_tools/gen_testdata.sh new file mode 100755 index 00000000..e995c276 --- /dev/null +++ b/kadai2/hiroya-w/_tools/gen_testdata.sh @@ -0,0 +1,6 @@ +#!/bin/sh +DIR=${1:-.} + +curl https://avatars.githubusercontent.com/hiroya-w -o $DIR/image_png.png +curl http://icb-lab.naist.jp/members/yoshi/ouec_lecture/image_recognition/image_files/lena.jpg -o $DIR/image_jpg.jpg +curl https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif -o $DIR/image_gif.gif diff --git a/kadai2/hiroya-w/cli.go b/kadai2/hiroya-w/cli.go new file mode 100644 index 00000000..2fdd4a62 --- /dev/null +++ b/kadai2/hiroya-w/cli.go @@ -0,0 +1,91 @@ +package imgconv + +import ( + "flag" + "fmt" + "io" + "os" +) + +// CLI is the command line interface +type CLI struct { + OutStream, ErrStream io.Writer +} + +// validateType validates the type of the image +func validateType(t string) error { + switch t { + case "jpg", "jpeg", "png", "gif": + return nil + default: + return fmt.Errorf("invalid type: %s", t) + } +} + +// Run parses the command line arguments and runs the imgConv +func (cli *CLI) Run() int { + config := &Config{} + fs := flag.NewFlagSet(os.Args[0], flag.ContinueOnError) + fs.StringVar(&config.InputType, "input-type", "jpg", "input type[jpg|jpeg|png|gif]") + fs.StringVar(&config.OutputType, "output-type", "png", "output type[jpg|jpeg|png|gif]") + fs.SetOutput(cli.ErrStream) + fs.Usage = func() { + fmt.Fprintf(cli.ErrStream, "Usage: %s [options] DIRECTORY\n", "imgconv") + fs.PrintDefaults() + } + + if err := fs.Parse(os.Args[1:]); err != nil { + fmt.Fprintf(cli.ErrStream, "Error parsing arguments: %s\n", err) + return 1 + } + + if err := validateType(config.InputType); err != nil { + fmt.Fprintf(cli.ErrStream, "invalid input type: %s\n", err) + return 1 + } + + if err := validateType(config.OutputType); err != nil { + fmt.Fprintf(cli.ErrStream, "invalid output type: %s\n", err) + return 1 + } + + if config.InputType == config.OutputType { + fmt.Fprintf(cli.ErrStream, "input type and output type must be different\n") + return 1 + } + + if fs.Arg(0) == "" { + fmt.Fprintf(cli.ErrStream, "directory is required\n") + return 1 + } + + config.Directory = fs.Arg(0) + + dec, err := NewDecoder(config.InputType) + if err != nil { + fmt.Fprintf(cli.ErrStream, "failed to create decoder: %s\n", err) + return 1 + } + enc, err := NewEncoder(config.OutputType) + if err != nil { + fmt.Fprintf(cli.ErrStream, "failed to create encoder: %s\n", err) + return 1 + } + imgConv := &ImgConv{ + Decoder: dec, + Encoder: enc, + TargetDir: config.Directory, + } + convertedFiles, err := imgConv.Run() + if err != nil { + fmt.Fprintf(cli.ErrStream, "failed to convert images: %s\n", err) + return 1 + } + + fmt.Fprintf(cli.OutStream, "converted %d files\n", len(convertedFiles)) + for _, f := range convertedFiles { + fmt.Fprintf(cli.OutStream, "%s\n", f) + } + + return 0 +} diff --git a/kadai2/hiroya-w/cli_test.go b/kadai2/hiroya-w/cli_test.go new file mode 100644 index 00000000..df0425e3 --- /dev/null +++ b/kadai2/hiroya-w/cli_test.go @@ -0,0 +1,46 @@ +package imgconv_test + +import ( + "bytes" + "fmt" + "os" + "strings" + "testing" + + imgconv "github.com/Hiroya-W/gopherdojo-studyroom/kadai2/hiroya-w" +) + +func TestCLI(t *testing.T) { + // t.Parallel() + tests := []struct { + options string + exitStatus int + want string + }{ + {options: "", exitStatus: 1, want: "directory is required"}, + {options: "-h", exitStatus: 1, want: "Usage"}, + {options: "-input-type=bmp testdata", exitStatus: 1, want: "invalid input type:"}, + {options: "-output-type=tiff testdata", exitStatus: 1, want: "invalid output type:"}, + {options: "-input-type=jpg -output-type=jpg testdata", exitStatus: 1, want: "input type and output type must be different"}, + {options: "testdata", exitStatus: 0, want: ""}, + } + + for _, test := range tests { + test := test + t.Run(fmt.Sprintf("Options:'%s'", test.options), func(t *testing.T) { + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli := &imgconv.CLI{OutStream: outStream, ErrStream: errStream} + + os.Args = append([]string{os.Args[0]}, strings.Split(test.options, " ")...) + exitStatus := cli.Run() + + if exitStatus != test.exitStatus { + t.Errorf("exit status = %d, want %d", exitStatus, test.exitStatus) + } + + if !strings.Contains(errStream.String(), test.want) { + t.Errorf("expected %q to eq %q", errStream.String(), test.want) + } + }) + } +} diff --git a/kadai2/hiroya-w/cmd/imgconv/main.go b/kadai2/hiroya-w/cmd/imgconv/main.go new file mode 100644 index 00000000..56f1920a --- /dev/null +++ b/kadai2/hiroya-w/cmd/imgconv/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "os" + + imgconv "github.com/Hiroya-W/gopherdojo-studyroom/kadai2/hiroya-w" +) + +func main() { + cli := &imgconv.CLI{OutStream: os.Stdout, ErrStream: os.Stderr} + os.Exit(cli.Run()) +} diff --git a/kadai2/hiroya-w/go.mod b/kadai2/hiroya-w/go.mod new file mode 100644 index 00000000..5f42022a --- /dev/null +++ b/kadai2/hiroya-w/go.mod @@ -0,0 +1,3 @@ +module github.com/Hiroya-W/gopherdojo-studyroom/kadai2/hiroya-w + +go 1.17 diff --git a/kadai2/hiroya-w/go.sum b/kadai2/hiroya-w/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/kadai2/hiroya-w/imgconv.go b/kadai2/hiroya-w/imgconv.go new file mode 100644 index 00000000..d263c095 --- /dev/null +++ b/kadai2/hiroya-w/imgconv.go @@ -0,0 +1,204 @@ +/* + Package imgconv provides image converter functions. + JPG, PNG, and GIF are supported. +*/ + +package imgconv + +import ( + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "log" + "os" + "path/filepath" +) + +// Decoder is an interface for image decoding. +type Decoder interface { + Decode(r io.Reader) (image.Image, error) + GetExt() string +} + +// Encoder is an interface for image encoding. +type Encoder interface { + Encode(w io.Writer, m image.Image) error + GetExt() string +} + +// Config is the configuration for arguments and flags. +type Config struct { + InputType string + OutputType string + Directory string +} + +// Extention is a struct for holding the file extension. +type Extention struct { + Ext string +} + +// GetExtt returns the file extension. +func (e *Extention) GetExt() string { + return e.Ext +} + +// ImageDecoder is an image decoder for jpg, png, and gif. +type ImageDecoder struct { + *Extention +} + +func (d *ImageDecoder) Decode(r io.Reader) (image.Image, error) { + img, _, err := image.Decode(r) + return img, err +} + +// JPGEncoder is an image encoder for jpg. +type JPGEncoder struct { + *Extention +} + +func (e *JPGEncoder) Encode(w io.Writer, m image.Image) error { + return jpeg.Encode(w, m, nil) +} + +// PNGEncoder is an image encoder for png. +type PNGEncoder struct { + *Extention +} + +func (e *PNGEncoder) Encode(w io.Writer, m image.Image) error { + return png.Encode(w, m) +} + +// GIFEncoder is an image encoder for gif. +type GIFEncoder struct { + *Extention +} + +func (e *GIFEncoder) Encode(w io.Writer, m image.Image) error { + return gif.Encode(w, m, nil) +} + +// ImgConv is the main struct for the image converter. +type ImgConv struct { + Decoder Decoder + Encoder Encoder + TargetDir string +} + +// NewDecoder returns a new image decoder. +func NewDecoder(inputType string) (Decoder, error) { + switch inputType { + case "jpg", "png", "gif": + return &ImageDecoder{&Extention{inputType}}, nil + default: + return nil, fmt.Errorf("%s is not a supported image type", inputType) + } +} + +// NewEncoder returns a new image encoder for the given outputType. +func NewEncoder(outputType string) (Encoder, error) { + switch outputType { + case "jpg": + return &JPGEncoder{&Extention{outputType}}, nil + case "png": + return &PNGEncoder{&Extention{outputType}}, nil + case "gif": + return &GIFEncoder{&Extention{outputType}}, nil + default: + return nil, fmt.Errorf("unsupported output type: %s", outputType) + } +} + +// renameExt renames the file extension of the file at filePath to newExt. +func renameExt(filePath, newExt string) string { + return filePath[:len(filePath)-len(filepath.Ext(filePath))] + "." + newExt +} + +// GetFiles returns a slice of file paths for specific extension in the target directory. +// Decoder.GetExt() is used to determine the extension. +func (c *ImgConv) GetFiles() ([]string, error) { + var imgPaths []string + + if f, err := os.Stat(c.TargetDir); err != nil { + return nil, err + } else if !f.IsDir() { + return nil, fmt.Errorf("%s is not a directory", c.TargetDir) + } + + err := filepath.Walk(c.TargetDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if filepath.Ext(path) == "."+c.Decoder.GetExt() { + imgPaths = append(imgPaths, path) + } + return nil + }) + + if err != nil { + return nil, err + } + + return imgPaths, nil +} + +// Convert converts an image file at filePath to the outputType. +func (c *ImgConv) Convert(dec Decoder, enc Encoder, filePath string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer func() { + if err := f.Close(); err != nil { + fmt.Printf("Error closing file: %s\n", err) + } + }() + + img, _, err := image.Decode(f) + if err != nil { + return "", err + } + + outputPath := renameExt(filePath, enc.GetExt()) + output, err := os.Create(outputPath) + if err != nil { + return "", err + } + defer func() { + if err := output.Close(); err != nil { + log.Printf("Error closing file: %s\n", err) + } + }() + + if err := enc.Encode(output, img); err != nil { + return "", err + } + return outputPath, nil +} + +// Run converts all images in the target directory to the outputType. +func (c *ImgConv) Run() ([]string, error) { + var convertedFiles []string + imgPaths, err := c.GetFiles() + if err != nil { + return nil, err + } + + for _, path := range imgPaths { + outputPath, err := c.Convert(c.Decoder, c.Encoder, path) + if err != nil { + return convertedFiles, err + } + convertedFiles = append(convertedFiles, outputPath) + } + + return convertedFiles, nil +} diff --git a/kadai2/hiroya-w/imgconv_test.go b/kadai2/hiroya-w/imgconv_test.go new file mode 100644 index 00000000..d54e2a8d --- /dev/null +++ b/kadai2/hiroya-w/imgconv_test.go @@ -0,0 +1,164 @@ +package imgconv_test + +import ( + "strings" + "testing" + + imgconv "github.com/Hiroya-W/gopherdojo-studyroom/kadai2/hiroya-w" +) + +func TestEncoder(t *testing.T) { + t.Parallel() + tests := []struct { + name string + OutputType string + }{ + {name: "toJPG", OutputType: "jpg"}, + {name: "toPNG", OutputType: "png"}, + {name: "toGIF", OutputType: "gif"}, + {name: "toTIFF", OutputType: "tiff"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := &imgconv.Config{ + OutputType: tt.OutputType, + } + enc, err := imgconv.NewEncoder(config.OutputType) + switch tt.OutputType { + case "jpg": + if err != nil { + t.Errorf("NewEncoder() error = %s", err) + } + if _, ok := enc.(*imgconv.JPGEncoder); !ok { + t.Errorf("It is not JPGEncoder. You get %T", enc) + } + case "png": + if err != nil { + t.Errorf("NewEncoder() error = %s", err) + } + if _, ok := enc.(*imgconv.PNGEncoder); !ok { + t.Errorf("It is not PNGEncoder. You get %T", enc) + } + case "gif": + if err != nil { + t.Errorf("NewEncoder() error = %s", err) + } + if _, ok := enc.(*imgconv.GIFEncoder); !ok { + t.Errorf("It is not GIFEncoder. You get %T", enc) + } + default: + if err == nil { + t.Errorf("NewEncoder needs to return an error. But enc = %T", enc) + } + } + }) + } +} + +func TestGetFiles(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputType string + directory string + want string + }{ + {name: "jpg", inputType: "jpg", directory: "testdata"}, + {name: "png", inputType: "png", directory: "testdata"}, + {name: "no_such_dir", inputType: "jpg", directory: "hogehoge", want: "no such file or directory"}, + {name: "no_such_dir", inputType: "jpg", directory: "cmd/imgconv/main.go", want: "is not a directory"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec, err := imgconv.NewDecoder(tt.inputType) + if err != nil { + t.Errorf("NewDecoder() error = %s", err) + } + + imgConv := &imgconv.ImgConv{ + Decoder: dec, + TargetDir: tt.directory, + } + _, err = imgConv.GetFiles() + if err != nil { + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("expected %q to eq %q", err.Error(), tt.want) + } + } + }) + } +} + +func TestGetFilesCount(t *testing.T) { + t.Parallel() + tests := []struct { + name string + inputType string + directory string + want int + }{ + {name: "go_files", inputType: "go", directory: ".", want: 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec := &imgconv.ImageDecoder{ + &imgconv.Extention{tt.inputType}, + } + imgConv := &imgconv.ImgConv{ + Decoder: dec, + TargetDir: tt.directory, + } + files, err := imgConv.GetFiles() + if err != nil { + t.Errorf("GetFiles() error = %s", err) + } + if len(files) != tt.want { + t.Errorf("expected %d to eq %d", len(files), tt.want) + } + }) + } +} + +func TestConvert(t *testing.T) { + // t.Parallel() + tests := []struct { + name string + inputType string + outputType string + inputFile string + want string + }{ + {name: "JPGtoPNG", inputType: "jpg", outputType: "png", inputFile: "testdata/image_jpg.jpg", want: "testdata/image_jpg.png"}, + {name: "JPGtoGIF", inputType: "jpg", outputType: "gif", inputFile: "testdata/image_jpg.jpg", want: "testdata/image_jpg.gif"}, + {name: "PNGtoJPG", inputType: "png", outputType: "jpg", inputFile: "testdata/image_png.png", want: "testdata/image_png.jpg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec, err := imgconv.NewDecoder(tt.inputType) + if err != nil { + t.Errorf("NewDecoder() error = %s", err) + } + enc, err := imgconv.NewEncoder(tt.outputType) + if err != nil { + t.Errorf("NewEncoder() error = %s", err) + } + + imgConv := &imgconv.ImgConv{ + Decoder: dec, + Encoder: enc, + } + outputPath, err := imgConv.Convert(dec, enc, tt.inputFile) + if err != nil { + t.Errorf("Convert() error = %s", err) + } + + if outputPath != tt.want { + t.Errorf("expected %q to eq %q", outputPath, tt.want) + } + }) + } +} diff --git a/kadai2/hiroya-w/testdata/.gitignore b/kadai2/hiroya-w/testdata/.gitignore new file mode 100644 index 00000000..e90edb82 --- /dev/null +++ b/kadai2/hiroya-w/testdata/.gitignore @@ -0,0 +1,4 @@ +*.png +*.jpg +*.jpeg +*.gif diff --git a/kadai2/hiroya-w/version.go b/kadai2/hiroya-w/version.go new file mode 100644 index 00000000..3e1f95d2 --- /dev/null +++ b/kadai2/hiroya-w/version.go @@ -0,0 +1,4 @@ +package imgconv + +//lint:ignore U1000 Ignore unused code. +const version string = "0.0.1"