Spaces:
Sleeping
Sleeping
Merge pull request #20 from sdsc-ordes/test/tests
Browse filesTest/tests: Implemented first unit tests, using both plain pytest and AppTest
- .github/workflows/python-pytest.yml +36 -0
- docs/dev_notes.md +46 -4
- pytest.ini +5 -0
- snippets/{test_upload.py → try_upload.py} +0 -0
- src/__init__.py +0 -0
- src/apptest/demo_whale_viewer.py +30 -0
- src/input_handling.py +58 -3
- tests/README.md +10 -0
- tests/data/cakes.jpg +0 -0
- tests/data/cakes_no_exif_datetime.jpg +0 -0
- tests/data/cakes_no_exif_gps.jpg +0 -0
- tests/requirements.txt +6 -0
- tests/test_demo_whale_viewer.py +129 -0
- tests/test_input_handling.py +208 -0
- tests/test_whale_viewer.py +50 -0
.github/workflows/python-pytest.yml
ADDED
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# This workflow will install Python dependencies, run tests and lint with a single version of Python
|
2 |
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
3 |
+
|
4 |
+
name: Execute tests with pytest
|
5 |
+
|
6 |
+
on:
|
7 |
+
push:
|
8 |
+
branches: [ "dev" ]
|
9 |
+
pull_request:
|
10 |
+
branches: [ "dev", "main" ]
|
11 |
+
permissions:
|
12 |
+
contents: read
|
13 |
+
jobs:
|
14 |
+
build:
|
15 |
+
runs-on: ubuntu-latest
|
16 |
+
steps:
|
17 |
+
- uses: actions/checkout@v4
|
18 |
+
- name: Set up Python 3.10
|
19 |
+
uses: actions/setup-python@v3
|
20 |
+
with:
|
21 |
+
python-version: "3.10"
|
22 |
+
- name: Install dependencies
|
23 |
+
run: |
|
24 |
+
python -m pip install --upgrade pip
|
25 |
+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
26 |
+
if [ -f tests/requirements.txt ]; then pip install -r tests/requirements.txt; fi
|
27 |
+
# if [ -f pyproject.toml ]; then pip install -r pyproject.toml; fi
|
28 |
+
#- name: Lint with flake8
|
29 |
+
# run: |
|
30 |
+
# # stop the build if there are Python syntax errors or undefined names
|
31 |
+
# flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
32 |
+
# # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
33 |
+
# flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
34 |
+
- name: Test with pytest
|
35 |
+
run: |
|
36 |
+
pytest
|
docs/dev_notes.md
CHANGED
@@ -5,7 +5,7 @@ We set this up so it is hosted as a huggingface space. Each commit to `main` tri
|
|
5 |
For local testing, assuming you have all the required packages installed in a
|
6 |
conda env or virtualenv, and that env is activated:
|
7 |
|
8 |
-
```
|
9 |
cd src
|
10 |
streamlit run main.py
|
11 |
```
|
@@ -17,15 +17,17 @@ We have a CI action to presesnt the docs on github.io.
|
|
17 |
To validate locally, you need the deps listed in `requirements.txt` installed.
|
18 |
|
19 |
Run
|
20 |
-
```
|
21 |
mkdocs serve
|
22 |
```
|
|
|
23 |
And navigate to the wish server running locally, by default: http://127.0.0.1:8888/
|
24 |
|
25 |
This automatically watches for changes in the markdown files, but if you edit the
|
26 |
something else like the docstrings in py files, triggering a rebuild in another terminal
|
27 |
refreshes the site, without having to quit and restart the server.
|
28 |
-
|
|
|
29 |
mkdocs build -c
|
30 |
```
|
31 |
|
@@ -37,4 +39,44 @@ mkdocs build -c
|
|
37 |
|
38 |
# Set up a conda env
|
39 |
|
40 |
-
(Standard stuff)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
For local testing, assuming you have all the required packages installed in a
|
6 |
conda env or virtualenv, and that env is activated:
|
7 |
|
8 |
+
```bash
|
9 |
cd src
|
10 |
streamlit run main.py
|
11 |
```
|
|
|
17 |
To validate locally, you need the deps listed in `requirements.txt` installed.
|
18 |
|
19 |
Run
|
20 |
+
```bash
|
21 |
mkdocs serve
|
22 |
```
|
23 |
+
|
24 |
And navigate to the wish server running locally, by default: http://127.0.0.1:8888/
|
25 |
|
26 |
This automatically watches for changes in the markdown files, but if you edit the
|
27 |
something else like the docstrings in py files, triggering a rebuild in another terminal
|
28 |
refreshes the site, without having to quit and restart the server.
|
29 |
+
|
30 |
+
```bash
|
31 |
mkdocs build -c
|
32 |
```
|
33 |
|
|
|
39 |
|
40 |
# Set up a conda env
|
41 |
|
42 |
+
(Standard stuff)
|
43 |
+
|
44 |
+
|
45 |
+
# Testing
|
46 |
+
|
47 |
+
## local testing
|
48 |
+
To run the tests locally, we have the standard dependencies of the project, plus the test runner dependencies.
|
49 |
+
|
50 |
+
```bash
|
51 |
+
pip install -r tests/requirements.txt
|
52 |
+
```
|
53 |
+
|
54 |
+
(If we migrate to using toml config, the test reqs could be consolidated into an optional section)
|
55 |
+
|
56 |
+
|
57 |
+
**Running tests**
|
58 |
+
from the project root, simply run:
|
59 |
+
|
60 |
+
```bash
|
61 |
+
pytest
|
62 |
+
# or pick a specific test file to run
|
63 |
+
pytest tests/test_whale_viewer.py
|
64 |
+
```
|
65 |
+
|
66 |
+
To generate a coverage report to screen (also run the tests):
|
67 |
+
```bash
|
68 |
+
pytest --cov=src
|
69 |
+
```
|
70 |
+
|
71 |
+
To generate reports on pass rate and coverage, to files:
|
72 |
+
```bash
|
73 |
+
pytest --junit-xml=test-results.xml
|
74 |
+
pytest --cov-report=lcov --cov=src
|
75 |
+
```
|
76 |
+
|
77 |
+
|
78 |
+
## CI testing
|
79 |
+
|
80 |
+
Initially we have an action setup that runs all tests in the `tests` directory, within the `test/tests` branch.
|
81 |
+
|
82 |
+
TODO: Add some test report & coverage badges to the README.
|
pytest.ini
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[pytest]
|
2 |
+
pythonpath = "src"
|
3 |
+
testpaths =
|
4 |
+
tests
|
5 |
+
|
snippets/{test_upload.py → try_upload.py}
RENAMED
File without changes
|
src/__init__.py
ADDED
File without changes
|
src/apptest/demo_whale_viewer.py
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# a minimal snippet for the whale viewer, for testing purposes
|
2 |
+
# - using AppTest to validate that the display_whale functionality
|
3 |
+
# is ok
|
4 |
+
# - currently placed in the src directory (not optimal) because
|
5 |
+
# I couldn't get pytest to pick it up from the tests directory.
|
6 |
+
# - TODO: find a cleaner solution for organisation (maybe just config to pytest?)
|
7 |
+
|
8 |
+
import streamlit as st
|
9 |
+
|
10 |
+
# to run streamlit from this subdir, we need the the src dir on the path
|
11 |
+
# NOTE: pytest doesn't need this to run the tests, but to develop the test
|
12 |
+
# harness is hard without running streamlit
|
13 |
+
import sys
|
14 |
+
from os import path
|
15 |
+
# src (parent from here)
|
16 |
+
src_dir = path.dirname( path.dirname( path.abspath(__file__) ) )
|
17 |
+
sys.path.append(src_dir)
|
18 |
+
|
19 |
+
|
20 |
+
import whale_viewer as sw_wv
|
21 |
+
|
22 |
+
# a menu to pick one of the images
|
23 |
+
title = st.title("Whale Viewer testing")
|
24 |
+
species = st.selectbox("Species", sw_wv.WHALE_CLASSES)
|
25 |
+
|
26 |
+
if species is not None:
|
27 |
+
# and display the image + reference
|
28 |
+
st.write(f"Selected species: {species}")
|
29 |
+
sw_wv.display_whale([species], 0, st)
|
30 |
+
|
src/input_handling.py
CHANGED
@@ -1,3 +1,4 @@
|
|
|
|
1 |
from PIL import Image
|
2 |
from PIL import ExifTags
|
3 |
import re
|
@@ -194,14 +195,68 @@ def get_image_datetime(image_file: UploadedFile) -> str | None:
|
|
194 |
image = Image.open(image_file)
|
195 |
exif_data = image._getexif()
|
196 |
if exif_data is not None:
|
197 |
-
|
198 |
-
|
199 |
-
return value
|
200 |
except Exception as e: # FIXME: what types of exception?
|
201 |
st.warning(f"Could not extract date from image metadata. (file: {image_file.name})")
|
202 |
# TODO: add to logger
|
203 |
return None
|
204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
|
206 |
# an arbitrary set of defaults so testing is less painful...
|
207 |
# ideally we add in some randomization to the defaults
|
|
|
1 |
+
from fractions import Fraction
|
2 |
from PIL import Image
|
3 |
from PIL import ExifTags
|
4 |
import re
|
|
|
195 |
image = Image.open(image_file)
|
196 |
exif_data = image._getexif()
|
197 |
if exif_data is not None:
|
198 |
+
if ExifTags.Base.DateTimeOriginal in exif_data:
|
199 |
+
return exif_data.get(ExifTags.Base.DateTimeOriginal)
|
|
|
200 |
except Exception as e: # FIXME: what types of exception?
|
201 |
st.warning(f"Could not extract date from image metadata. (file: {image_file.name})")
|
202 |
# TODO: add to logger
|
203 |
return None
|
204 |
|
205 |
+
def decimal_coords(coords:tuple, ref:str) -> Fraction:
|
206 |
+
"""
|
207 |
+
Converts coordinates from degrees, minutes, and seconds to decimal degrees.
|
208 |
+
|
209 |
+
Args:
|
210 |
+
coords (tuple): A tuple containing three elements representing degrees, minutes, and seconds.
|
211 |
+
ref (str): A string representing the reference direction ('N', 'S', 'E', 'W').
|
212 |
+
|
213 |
+
Returns:
|
214 |
+
Fraction: The coordinates in decimal degrees. Negative if the reference is 'S' or 'W'.
|
215 |
+
|
216 |
+
Example:
|
217 |
+
decimal_coords((40, 26, 46), 'N') -> 40.44611111111111
|
218 |
+
decimal_coords((40, 26, 46), 'W') -> -40.44611111111111
|
219 |
+
"""
|
220 |
+
# https://stackoverflow.com/a/73267185
|
221 |
+
if ref not in ['N', 'S', 'E', 'W']:
|
222 |
+
raise ValueError("Invalid reference direction. Must be 'N', 'S', 'E', or 'W'.")
|
223 |
+
if len(coords) != 3:
|
224 |
+
raise ValueError("Coordinates must be a tuple of three elements (degrees, minutes, seconds).")
|
225 |
+
|
226 |
+
decimal_degrees = coords[0] + coords[1] / 60 + coords[2] / 3600
|
227 |
+
if ref == "S" or ref =='W':
|
228 |
+
decimal_degrees = -decimal_degrees
|
229 |
+
return decimal_degrees
|
230 |
+
|
231 |
+
|
232 |
+
def get_image_latlon(image_file: UploadedFile) -> tuple[float, float] | None:
|
233 |
+
"""
|
234 |
+
Extracts the latitude and longitude from the EXIF metadata of an uploaded image file.
|
235 |
+
|
236 |
+
Args:
|
237 |
+
image_file (UploadedFile): The uploaded image file from which to extract the latitude and longitude.
|
238 |
+
|
239 |
+
Returns:
|
240 |
+
tuple[float, float]: The latitude and longitude as a tuple if available, otherwise None.
|
241 |
+
|
242 |
+
Raises:
|
243 |
+
Warning: If the latitude and longitude could not be extracted from the image metadata.
|
244 |
+
"""
|
245 |
+
try:
|
246 |
+
image = Image.open(image_file)
|
247 |
+
exif_data = image._getexif()
|
248 |
+
if exif_data is not None:
|
249 |
+
if ExifTags.Base.GPSInfo in exif_data:
|
250 |
+
gps_ifd = exif_data.get(ExifTags.Base.GPSInfo)
|
251 |
+
|
252 |
+
lat = float(decimal_coords(gps_ifd[ExifTags.GPS.GPSLatitude], gps_ifd[ExifTags.GPS.GPSLatitudeRef]))
|
253 |
+
lon = float(decimal_coords(gps_ifd[ExifTags.GPS.GPSLongitude], gps_ifd[ExifTags.GPS.GPSLongitudeRef]))
|
254 |
+
|
255 |
+
return lat, lon
|
256 |
+
|
257 |
+
except Exception as e: # FIXME: what types of exception?
|
258 |
+
st.warning(f"Could not extract latitude and longitude from image metadata. (file: {str(image_file)}")
|
259 |
+
|
260 |
|
261 |
# an arbitrary set of defaults so testing is less painful...
|
262 |
# ideally we add in some randomization to the defaults
|
tests/README.md
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Test report
|
2 |
+
|
3 |
+
- overall status: 
|
4 |
+
- more detailed test report: TODO
|
5 |
+
|
6 |
+
## Test coverage
|
7 |
+
|
8 |
+
- TODO
|
9 |
+
- For a summary: one way is using https://github.com/GaelGirodon/ci-badges-action, can add it as a post-pytest step to the CI
|
10 |
+
- For a table: try this https://github.com/coroo/pytest-coverage-commentator
|
tests/data/cakes.jpg
ADDED
![]() |
tests/data/cakes_no_exif_datetime.jpg
ADDED
![]() |
tests/data/cakes_no_exif_gps.jpg
ADDED
![]() |
tests/requirements.txt
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# tests
|
2 |
+
pytest~=8.3.4
|
3 |
+
pytest-cov~=6.0.0
|
4 |
+
# linting
|
5 |
+
#flake8
|
6 |
+
|
tests/test_demo_whale_viewer.py
ADDED
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from streamlit.testing.v1 import AppTest
|
2 |
+
import pytest # for the exception testing
|
3 |
+
|
4 |
+
import whale_viewer as sw_wv # for data
|
5 |
+
|
6 |
+
|
7 |
+
def test_selectbox_ok():
|
8 |
+
'''
|
9 |
+
test the snippet demoing whale viewer - relating to AppTest'able elements
|
10 |
+
|
11 |
+
we validate that
|
12 |
+
- there is one selectbox present, with initial value "beluga" and index 0
|
13 |
+
- the two markdown elems generated dynamically by the selection corresponds
|
14 |
+
|
15 |
+
- then changing the selection, we do the same checks again
|
16 |
+
|
17 |
+
- finally, we check there are the right number of options (26)
|
18 |
+
|
19 |
+
'''
|
20 |
+
at = AppTest.from_file("src/apptest/demo_whale_viewer.py").run()
|
21 |
+
assert len(at.selectbox) == 1
|
22 |
+
assert at.selectbox[0].value == "beluga"
|
23 |
+
assert at.selectbox[0].index == 0
|
24 |
+
|
25 |
+
# let's check that the markdown is right
|
26 |
+
# the first markdown should be "Selected species: beluga"
|
27 |
+
assert at.markdown[0].value == "Selected species: beluga"
|
28 |
+
# the second markdown should be "### :whale: #1: Beluga"
|
29 |
+
print("markdown 1: ", at.markdown[1].value)
|
30 |
+
assert at.markdown[1].value == "### :whale: #1: Beluga"
|
31 |
+
|
32 |
+
# now let's select a different element. index 4 is commersons_dolphin
|
33 |
+
v4 = "commersons_dolphin"
|
34 |
+
v4_str = v4.replace("_", " ").title()
|
35 |
+
|
36 |
+
at.selectbox[0].set_value(v4).run()
|
37 |
+
assert at.selectbox[0].value == v4
|
38 |
+
assert at.selectbox[0].index == 4
|
39 |
+
# the first markdown should be "Selected species: commersons_dolphin"
|
40 |
+
assert at.markdown[0].value == f"Selected species: {v4}"
|
41 |
+
# the second markdown should be "### :whale: #1: Commersons Dolphin"
|
42 |
+
assert at.markdown[1].value == f"### :whale: #1: {v4_str}"
|
43 |
+
|
44 |
+
# test there are the right number of options
|
45 |
+
print("PROPS=> ", dir(at.selectbox[0])) # no length unfortunately,
|
46 |
+
# test it dynamically intead.
|
47 |
+
# should be fine
|
48 |
+
at.selectbox[0].select_index(len(sw_wv.WHALE_CLASSES)-1).run()
|
49 |
+
# should fail
|
50 |
+
with pytest.raises(Exception):
|
51 |
+
at.selectbox[0].select_index(len(sw_wv.WHALE_CLASSES)).run()
|
52 |
+
|
53 |
+
def test_img_props():
|
54 |
+
'''
|
55 |
+
test the snippet demoing whale viewer - relating to the image
|
56 |
+
|
57 |
+
we validate that
|
58 |
+
- one image is displayed
|
59 |
+
- the caption corresponds to the data in WHALE_REFERENCES
|
60 |
+
- the url is a mock url
|
61 |
+
|
62 |
+
- then changing the image, we do the same checks again
|
63 |
+
|
64 |
+
'''
|
65 |
+
at = AppTest.from_file("src/apptest/demo_whale_viewer.py").run()
|
66 |
+
ix = 0 # we didn't interact with the dropdown, so it should be the first one
|
67 |
+
# could fetch the property - maybe better in case code example changes
|
68 |
+
ix = at.selectbox[0].index
|
69 |
+
|
70 |
+
elem = at.get("imgs") # hmm, apparently the naming is not consistent with the other AppTest f/w.
|
71 |
+
# type(elem[0]) -> "streamlit.testing.v1.element_tree.UnknownElement" haha
|
72 |
+
assert len(elem) == 1
|
73 |
+
img0 = elem[0]
|
74 |
+
|
75 |
+
# we can't check the image, but maybe the alt text?
|
76 |
+
#assert at.image[0].alt == "beluga" # no, doesn't have that property.
|
77 |
+
|
78 |
+
# for v1.39, the proto comes back something like this:
|
79 |
+
exp_proto = '''
|
80 |
+
imgs {
|
81 |
+
caption: "https://www.fisheries.noaa.gov/species/beluga-whale"
|
82 |
+
url: "/mock/media/6a21db178fcd99b82817906fc716a5c35117f4daa1d1c1d3c16ae1c8.png"
|
83 |
+
}
|
84 |
+
width: -3
|
85 |
+
'''
|
86 |
+
# from the proto string we can look for <itemtype>: "<value>" pairs and make a dictionary
|
87 |
+
import re
|
88 |
+
|
89 |
+
def parse_proto(proto_str):
|
90 |
+
pattern = r'(\w+):\s*"([^"]+)"'
|
91 |
+
matches = re.findall(pattern, proto_str)
|
92 |
+
return {key: value for key, value in matches}
|
93 |
+
|
94 |
+
parsed_proto = parse_proto(str(img0.proto))
|
95 |
+
# we're expecting the caption to be WHALE_REFERENCES[ix]
|
96 |
+
print(parsed_proto)
|
97 |
+
assert "caption" in parsed_proto
|
98 |
+
assert parsed_proto["caption"] == sw_wv.WHALE_REFERENCES[ix]
|
99 |
+
assert "url" in parsed_proto
|
100 |
+
assert parsed_proto["url"].startswith("/mock/media")
|
101 |
+
|
102 |
+
print(sw_wv.WHALE_REFERENCES[ix])
|
103 |
+
|
104 |
+
# now let's switch to another index
|
105 |
+
ix = 15
|
106 |
+
v15 = sw_wv.WHALE_CLASSES[ix]
|
107 |
+
v15_str = v15.replace("_", " ").title()
|
108 |
+
at.selectbox[0].set_value(v15).run()
|
109 |
+
|
110 |
+
elem = at.get("imgs")
|
111 |
+
img0 = elem[0]
|
112 |
+
print("[INFO] image 0 after adjusting dropdown:")
|
113 |
+
print(img0.type, type(img0.proto))#, "\t", i0.value) # it doesn't have a value
|
114 |
+
print(img0.proto)
|
115 |
+
|
116 |
+
|
117 |
+
parsed_proto = parse_proto(str(img0.proto))
|
118 |
+
# we're expecting the caption to be WHALE_REFERENCES[ix]
|
119 |
+
print(parsed_proto)
|
120 |
+
assert "caption" in parsed_proto
|
121 |
+
assert parsed_proto["caption"] == sw_wv.WHALE_REFERENCES[ix]
|
122 |
+
assert "url" in parsed_proto
|
123 |
+
assert parsed_proto["url"].startswith("/mock/media")
|
124 |
+
|
125 |
+
|
126 |
+
|
127 |
+
|
128 |
+
|
129 |
+
|
tests/test_input_handling.py
ADDED
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
from input_handling import is_valid_email, is_valid_number
|
5 |
+
from input_handling import get_image_datetime, get_image_latlon, decimal_coords
|
6 |
+
|
7 |
+
# generate tests for is_valid_email
|
8 |
+
# - test with valid email
|
9 |
+
# - basic email with @ and .
|
10 |
+
# - test with email with multiple .
|
11 |
+
# - test with empty email
|
12 |
+
# - test with None email
|
13 |
+
# - test with non-string email
|
14 |
+
# - test with invalid email
|
15 |
+
# - test with email without @
|
16 |
+
# - test with email without .
|
17 |
+
# - test with email without domain
|
18 |
+
# - test with email without username
|
19 |
+
# - test with email without TLD
|
20 |
+
# - test with email with multiple @
|
21 |
+
# - test with email starting with the + sign
|
22 |
+
|
23 |
+
|
24 |
+
def test_is_valid_email_valid():
|
25 |
+
assert is_valid_email("[email protected]")
|
26 |
+
assert is_valid_email("[email protected]")
|
27 |
+
assert is_valid_email("[email protected]")
|
28 |
+
assert is_valid_email("[email protected]")
|
29 |
+
assert is_valid_email("[email protected]")
|
30 |
+
|
31 |
+
def test_is_valid_email_empty():
|
32 |
+
assert not is_valid_email("")
|
33 |
+
|
34 |
+
def test_is_valid_email_none():
|
35 |
+
with pytest.raises(TypeError):
|
36 |
+
is_valid_email(None)
|
37 |
+
|
38 |
+
def test_is_valid_email_non_string():
|
39 |
+
with pytest.raises(TypeError):
|
40 |
+
is_valid_email(123)
|
41 |
+
|
42 |
+
|
43 |
+
def test_is_valid_email_invalid():
|
44 |
+
assert not is_valid_email("a.bc")
|
45 |
+
assert not is_valid_email("a@bc")
|
46 |
+
assert not is_valid_email("a.b@cc")
|
47 |
+
assert not is_valid_email("@b.cc")
|
48 |
+
assert not is_valid_email("[email protected]")
|
49 |
+
assert not is_valid_email("a@b.")
|
50 |
+
assert not is_valid_email("a@bb.")
|
51 |
+
assert not is_valid_email("[email protected].")
|
52 |
+
assert not is_valid_email("a@[email protected]")
|
53 |
+
|
54 |
+
# not sure how xfails come through the CI pipeline yet.
|
55 |
+
# maybe better to just comment out this stuff until pipeline is setup, then can check /extend
|
56 |
+
@pytest.mark.xfail(reason="Bug identified, but while setting up CI having failing tests causes more headache")
|
57 |
+
def test_is_valid_email_invalid_plus():
|
58 |
+
assert not is_valid_email("[email protected]")
|
59 |
+
assert not is_valid_email("[email protected]")
|
60 |
+
|
61 |
+
|
62 |
+
def test_is_valid_number_valid():
|
63 |
+
# with a sign or without, fractional or integer are all valid
|
64 |
+
assert is_valid_number("123")
|
65 |
+
assert is_valid_number("123.456")
|
66 |
+
assert is_valid_number("-123")
|
67 |
+
assert is_valid_number("-123.456")
|
68 |
+
assert is_valid_number("+123")
|
69 |
+
assert is_valid_number("+123.456")
|
70 |
+
|
71 |
+
def test_is_valid_number_empty():
|
72 |
+
assert not is_valid_number("")
|
73 |
+
|
74 |
+
def test_is_valid_number_none():
|
75 |
+
with pytest.raises(TypeError):
|
76 |
+
is_valid_number(None)
|
77 |
+
|
78 |
+
def test_is_valid_number_invalid():
|
79 |
+
# func should return False for strings that are not numbers
|
80 |
+
assert not is_valid_number("abc")
|
81 |
+
assert not is_valid_number("123abc")
|
82 |
+
assert not is_valid_number("abc123")
|
83 |
+
assert not is_valid_number("123.456.789")
|
84 |
+
assert not is_valid_number("123,456")
|
85 |
+
assert not is_valid_number("123-456")
|
86 |
+
assert not is_valid_number("123+456")
|
87 |
+
def test_is_valid_number_valid():
|
88 |
+
assert is_valid_number("123")
|
89 |
+
assert is_valid_number("123.456")
|
90 |
+
assert is_valid_number("-123")
|
91 |
+
assert is_valid_number("-123.456")
|
92 |
+
assert is_valid_number("+123")
|
93 |
+
assert is_valid_number("+123.456")
|
94 |
+
|
95 |
+
def test_is_valid_number_empty():
|
96 |
+
assert not is_valid_number("")
|
97 |
+
|
98 |
+
def test_is_valid_number_none():
|
99 |
+
with pytest.raises(TypeError):
|
100 |
+
is_valid_number(None)
|
101 |
+
|
102 |
+
def test_is_valid_number_invalid():
|
103 |
+
assert not is_valid_number("abc")
|
104 |
+
assert not is_valid_number("123abc")
|
105 |
+
assert not is_valid_number("abc123")
|
106 |
+
assert not is_valid_number("123.456.789")
|
107 |
+
assert not is_valid_number("123,456")
|
108 |
+
assert not is_valid_number("123-456")
|
109 |
+
assert not is_valid_number("123+456")
|
110 |
+
|
111 |
+
|
112 |
+
|
113 |
+
# tests for get_image_datetime
|
114 |
+
# - testing with a valid image with complete, valid metadata
|
115 |
+
# - testing with a valid image with incomplete metadata (missing datetime info -- that's a legitimate case we should handle)
|
116 |
+
# - testing with a valid image with incomplete metadata (missing GPS info -- should not affect the datetime extraction)
|
117 |
+
# - testing with a valid image with no metadata
|
118 |
+
# - timezones too
|
119 |
+
|
120 |
+
|
121 |
+
test_data_pth = Path('tests/data/')
|
122 |
+
def test_get_image_datetime():
|
123 |
+
|
124 |
+
# this image has lat, lon, and datetime
|
125 |
+
f1 = test_data_pth / 'cakes.jpg'
|
126 |
+
assert get_image_datetime(f1) == "2024:10:24 15:59:45"
|
127 |
+
#"+02:00"
|
128 |
+
# hmm, the full datetime requires timezone, which is called OffsetTimeOriginal
|
129 |
+
|
130 |
+
# missing GPS loc: this should not interfere with the datetime
|
131 |
+
f2 = test_data_pth / 'cakes_no_exif_gps.jpg'
|
132 |
+
assert get_image_datetime(f2) == "2024:10:24 15:59:45"
|
133 |
+
|
134 |
+
# missng datetime -> expect None
|
135 |
+
f3 = test_data_pth / 'cakes_no_exif_datetime.jpg'
|
136 |
+
assert get_image_datetime(f3) == None
|
137 |
+
|
138 |
+
|
139 |
+
def test_get_image_latlon():
|
140 |
+
# this image has lat, lon, and datetime
|
141 |
+
f1 = test_data_pth / 'cakes.jpg'
|
142 |
+
assert get_image_latlon(f1) == (46.51860277777778, 6.562075)
|
143 |
+
|
144 |
+
# missing GPS loc
|
145 |
+
f2 = test_data_pth / 'cakes_no_exif_gps.jpg'
|
146 |
+
assert get_image_latlon(f2) == None
|
147 |
+
|
148 |
+
# missng datetime -> expect gps not affected
|
149 |
+
f3 = test_data_pth / 'cakes_no_exif_datetime.jpg'
|
150 |
+
assert get_image_latlon(f3) == (46.51860277777778, 6.562075)
|
151 |
+
|
152 |
+
# tests for get_image_latlon with empty file
|
153 |
+
def test_get_image_latlon_empty():
|
154 |
+
assert get_image_latlon("") == None
|
155 |
+
|
156 |
+
# tests for decimal_coords
|
157 |
+
# - without input, py raises TypeError
|
158 |
+
# - with the wrong length of input (expecting 3 elements in the tuple), expect ValueError
|
159 |
+
# - with string inputs instead of numeric, we get a TypeError (should the func bother checking this? happens as built in)
|
160 |
+
# - with ref direction not in ['N', 'S', 'E', 'W'], expect ValueError, try X, x, NW.
|
161 |
+
# - with valid inputs, expect the correct output
|
162 |
+
|
163 |
+
|
164 |
+
# test data for decimal_coords: (deg,min,sec), ref, expected output
|
165 |
+
coords_conversion_data = [
|
166 |
+
((30, 1, 2), 'W', -30.01722222),
|
167 |
+
((30, 1, 2), 'E', 30.01722222),
|
168 |
+
((30, 1, 2), 'N', 30.01722222),
|
169 |
+
((30, 1, 2), 'S', -30.01722222),
|
170 |
+
((46, 31, 6.97), 'N', 46.51860278),
|
171 |
+
((6, 33, 43.47), 'E', 6.56207500)
|
172 |
+
]
|
173 |
+
@pytest.mark.parametrize("input_coords, ref, expected_output", coords_conversion_data)
|
174 |
+
def test_decimal_coords(input_coords, ref, expected_output):
|
175 |
+
assert decimal_coords(input_coords, ref) == pytest.approx(expected_output)
|
176 |
+
|
177 |
+
def test_decimal_coords_no_input():
|
178 |
+
with pytest.raises(TypeError):
|
179 |
+
decimal_coords()
|
180 |
+
|
181 |
+
def test_decimal_coords_wrong_length():
|
182 |
+
with pytest.raises(ValueError):
|
183 |
+
decimal_coords((1, 2), 'W')
|
184 |
+
|
185 |
+
with pytest.raises(ValueError):
|
186 |
+
decimal_coords((30,), 'W')
|
187 |
+
|
188 |
+
with pytest.raises(ValueError):
|
189 |
+
decimal_coords((30, 1, 2, 4), 'W')
|
190 |
+
|
191 |
+
def test_decimal_coords_non_numeric():
|
192 |
+
with pytest.raises(TypeError):
|
193 |
+
decimal_coords(('1', '2', '3'), 'W')
|
194 |
+
|
195 |
+
|
196 |
+
def test_decimal_coords_invalid_ref():
|
197 |
+
with pytest.raises(ValueError):
|
198 |
+
decimal_coords((30, 1, 2), 'X')
|
199 |
+
|
200 |
+
with pytest.raises(ValueError):
|
201 |
+
decimal_coords((30, 1, 2), 'x')
|
202 |
+
|
203 |
+
with pytest.raises(ValueError):
|
204 |
+
decimal_coords((30, 1, 2), 'NW')
|
205 |
+
|
206 |
+
|
207 |
+
|
208 |
+
|
tests/test_whale_viewer.py
ADDED
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import pytest
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
from whale_viewer import format_whale_name
|
5 |
+
|
6 |
+
# testing format_whale_name
|
7 |
+
# - testing with valid whale names
|
8 |
+
# - testing with invalid whale names
|
9 |
+
# - empty string
|
10 |
+
# - with the wrong datatype
|
11 |
+
|
12 |
+
def test_format_whale_name_ok():
|
13 |
+
# some with 1 word, most with 2 words, others with 3 or 4.
|
14 |
+
assert format_whale_name("right_whale") == "Right Whale"
|
15 |
+
assert format_whale_name("blue_whale") == "Blue Whale"
|
16 |
+
assert format_whale_name("humpback_whale") == "Humpback Whale"
|
17 |
+
assert format_whale_name("sperm_whale") == "Sperm Whale"
|
18 |
+
assert format_whale_name("fin_whale") == "Fin Whale"
|
19 |
+
assert format_whale_name("sei_whale") == "Sei Whale"
|
20 |
+
assert format_whale_name("minke_whale") == "Minke Whale"
|
21 |
+
assert format_whale_name("gray_whale") == "Gray Whale"
|
22 |
+
assert format_whale_name("bowhead_whale") == "Bowhead Whale"
|
23 |
+
assert format_whale_name("beluga") == "Beluga"
|
24 |
+
|
25 |
+
assert format_whale_name("long_finned_pilot_whale") == "Long Finned Pilot Whale"
|
26 |
+
assert format_whale_name("melon_headed_whale") == "Melon Headed Whale"
|
27 |
+
assert format_whale_name("pantropic_spotted_dolphin") == "Pantropic Spotted Dolphin"
|
28 |
+
assert format_whale_name("spotted_dolphin") == "Spotted Dolphin"
|
29 |
+
assert format_whale_name("killer_whale") == "Killer Whale"
|
30 |
+
|
31 |
+
|
32 |
+
def test_format_whale_name_invalid():
|
33 |
+
# not so clear what this would be, except perhaps a string that has gone through the fucn alrealdy?
|
34 |
+
assert format_whale_name("Right Whale") == "Right Whale"
|
35 |
+
assert format_whale_name("Blue Whale") == "Blue Whale"
|
36 |
+
assert format_whale_name("Long Finned Pilot Whale") == "Long Finned Pilot Whale"
|
37 |
+
|
38 |
+
# testing with empty string
|
39 |
+
def test_format_whale_name_empty():
|
40 |
+
assert format_whale_name("") == ""
|
41 |
+
|
42 |
+
# testing with the wrong datatype
|
43 |
+
# we should get a TypeError - currently it fails with a AttributeError
|
44 |
+
@pytest.mark.xfail
|
45 |
+
def test_format_whale_name_none():
|
46 |
+
with pytest.raises(TypeError):
|
47 |
+
format_whale_name(None)
|
48 |
+
|
49 |
+
|
50 |
+
# display_whale requires UI to test it.
|