diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c1a500d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,19 @@ +name: Tests +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go stable + uses: actions/setup-go@v5 + with: + go-version: 'stable' + - name: "Running unit tests" + run: go test ./... -v \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1f5d6dd..6b88329 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ WORKDIR /app # Copy source files COPY . . +# Run unit tests +RUN go test ./... -v + # Build the application RUN go build -ldflags="-s -w" -o zephyr diff --git a/README.md b/README.md index eadf520..8ab9d24 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@
real-time weather forecast service
[![](https://github.com/ceticamarco/zephyr/actions/workflows/docker.yml/badge.svg)](https://github.com/ceticamarco/zephyr/actions/workflows/docker.yml) +[![](https://github.com/ceticamarco/zephyr/actions/workflows/tests.yml/badge.svg)](https://github.com/ceticamarco/zephyr/actions/workflows/tests.yml) @@ -163,7 +164,7 @@ will yield } ``` -> [!INFO] +> [!NOTE] > To convert OpenWeatherMap's moon phase value to the illumination percentage, > I've used the following formula: > @@ -333,5 +334,12 @@ This will build the container image and start the service in detached mode. By d the service will be available at `http://127.0.0.1:3000`, but you can easily change this property but editing the `compose.yml` as stated above. +## Unit tests +You can run the unit tests by issuing the following command: + +```sh + go test ./... -v + ``` + ## License This software is released under the GPLv3 license. You can find a copy of the license with this repository or by visiting the [following page](https://choosealicense.com/licenses/gpl-3.0/). \ No newline at end of file diff --git a/model/windModel_test.go b/model/windModel_test.go new file mode 100644 index 0000000..d9d8f2c --- /dev/null +++ b/model/windModel_test.go @@ -0,0 +1,28 @@ +package model + +import ( + "testing" +) + +type TestEntry struct { + Name string + Input float64 + Expected string +} + +func TestGetCardinalDir(t *testing.T) { + tests := []TestEntry{ + {"Bounded value", 65.4, "ENE"}, + {"Out of bound value", 450.3, "E"}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got, _ := GetCardinalDir(test.Input) + + if got != test.Expected { + t.Errorf("Got %s, wanted %s", got, test.Expected) + } + }) + } +} diff --git a/statistics/primitives_test.go b/statistics/primitives_test.go new file mode 100644 index 0000000..557e8ed --- /dev/null +++ b/statistics/primitives_test.go @@ -0,0 +1,123 @@ +package statistics + +import ( + "math" + "testing" +) + +type TestEntry struct { + Name string + Input []float64 + Expected float64 +} + +func cmpVal(x, y float64) bool { + const epsilon = 1e-9 + + return math.Abs(x-y) < epsilon +} + +func TestMean(t *testing.T) { + tests := []TestEntry{ + {"Empty list", []float64{}, 0}, + {"Single element", []float64{5.0}, 5.0}, + {"Multiple elements", []float64{2.3, 6.4, -2.2, 8.4}, 3.725}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := Mean(test.Input) + if !cmpVal(got, test.Expected) { + t.Errorf("Got %v, wanted %v", got, test.Expected) + } + }) + } +} + +func TestStdDev(t *testing.T) { + tests := []TestEntry{ + {"Empty list", []float64{}, 0}, + {"Single element", []float64{5.0}, 0}, + {"Multiple elements", []float64{5.0, -4.2, 3.4, 7.2}, 4.288064831599448}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := StdDev(test.Input) + if !cmpVal(got, test.Expected) { + t.Errorf("Got %v, wanted %v", got, test.Expected) + } + }) + } +} + +func TestMedian(t *testing.T) { + tests := []TestEntry{ + {"Empty list", []float64{}, 0}, + {"Single element", []float64{5.0}, 5.0}, + {"Multiple elements (even)", []float64{5.0, -4.2, 3.4, 7.2}, 4.2}, + {"Multiple elements (odd)", []float64{5.0, -4.2, 1.4, 3.4, 7.2}, 3.4}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := Median(test.Input) + if !cmpVal(got, test.Expected) { + t.Errorf("Got %v, wanted %v", got, test.Expected) + } + }) + } +} + +func TestMode(t *testing.T) { + tests := []TestEntry{ + {"Empty list", []float64{}, 0}, + {"Single element", []float64{5.0}, 5.0}, + {"Unique modes", []float64{1.0, 2.0, 2.0, 3.0}, 2.0}, + {"Multi-modal", []float64{1.0, 1.0, 2.0, 3.0, 3.0}, 3.0}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := Mode(test.Input) + if !cmpVal(got, test.Expected) { + t.Errorf("Got %v, wanted %v", got, test.Expected) + } + }) + } +} + +func TestRobustZScore(t *testing.T) { + // Gaussian distributed dataset representing "normal" + // temperatures; that is, without anomalies + normalTemps := []float64{ + 18.0, 19.0, 19.0, 20.0, 20.0, + 20.0, 21.0, 21.0, 21.0, 21.0, + 22.0, 22.0, 22.0, 22.0, 22.0, + 23.0, 23.0, 23.0, 24.0, 24.0, + } + + tests := []TestEntry{ + {"Empty list", []float64{}, 0}, + {"Single element", []float64{20.0}, 0}, + {"Temperatures without anomalies", normalTemps, 0}, + {"High anomaly", append(normalTemps, 30.0), 30.0}, + {"Low anomaly", append(normalTemps, 5.0), 5.0}, + } + + for _, test := range tests { + t.Run(test.Name, func(t *testing.T) { + got := RobustZScore(test.Input) + + if len(got) != 0 { + if !cmpVal(got[0].Value, test.Expected) { + t.Errorf("Got %v, wanted %v", got, test.Expected) + } + } else { + if test.Expected != 0 { + t.Errorf("Got [], wanted %v", test.Expected) + } + } + }) + } +}