vancauwe commited on
Commit
330b67d
·
2 Parent(s): d8c87e7 5897761

Merge branch 'dev' into feat/image-batch

Browse files
.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/main.py CHANGED
@@ -94,8 +94,8 @@ def main() -> None:
94
  #g_logger.warning("warning message")
95
 
96
  # Streamlit app
97
- #tab_gallery, tab_inference, tab_hotdogs, tab_map, tab_data, tab_log = st.tabs(["Cetecean classifier", "Hotdog classifier", "Map", "observation", "Log", "Beautiful cetaceans"])
98
- tab_inference, tab_hotdogs, tab_map, tab_data, tab_log, tab_gallery = st.tabs(["Cetecean classifier", "Hotdog classifier", "Map", "observation", "Log", "Beautiful cetaceans"])
99
  st.session_state.tab_log = tab_log
100
 
101
 
@@ -118,6 +118,7 @@ def main() -> None:
118
  with tab_map:
119
  # visual structure: a couple of toggles at the top, then the map inlcuding a
120
  # dropdown for tileset selection.
 
121
  tab_map_ui_cols = st.columns(2)
122
  with tab_map_ui_cols[0]:
123
  show_db_points = st.toggle("Show Points from DB", True)
@@ -146,9 +147,13 @@ def main() -> None:
146
 
147
 
148
 
149
- with tab_data:
150
  # the goal of this tab is to allow selection of the new obsvation's location by map click/adjust.
151
- st.markdown("Coming later hope! :construction:")
 
 
 
 
152
 
153
  st.write("Click on the map to capture a location.")
154
  #m = folium.Map(location=visp_loc, zoom_start=7)
@@ -180,7 +185,7 @@ def main() -> None:
180
  tab_log.info(f"{st.session_state.observations}")
181
 
182
  df = pd.DataFrame(submitted_data, index=[0])
183
- with tab_data:
184
  st.table(df)
185
 
186
 
@@ -192,12 +197,17 @@ def main() -> None:
192
  # - these species are shown
193
  # - the user can override the species prediction using the dropdown
194
  # - an observation is uploaded if the user chooses.
 
 
 
 
195
 
196
  if tab_inference.button("Identify with cetacean classifier"):
197
  #pipe = pipeline("image-classification", model="Saving-Willy/cetacean-classifier", trust_remote_code=True)
198
  cetacean_classifier = AutoModelForImageClassification.from_pretrained("Saving-Willy/cetacean-classifier",
199
  revision=classifier_revision,
200
  trust_remote_code=True)
 
201
 
202
  if st.session_state.images is None:
203
  # TODO: cleaner design to disable the button until data input done?
@@ -212,11 +222,15 @@ def main() -> None:
212
  # purposes, an hotdog image classifier) which will be run locally.
213
  # - this model predicts if the image is a hotdog or not, and returns probabilities
214
  # - the input image is the same as for the ceteacean classifier - defined in the sidebar
 
 
 
 
 
215
 
216
  if tab_hotdogs.button("Get Hotdog Prediction"):
217
 
218
  pipeline_hot_dog = pipeline(task="image-classification", model="julien-c/hotdog-not-hotdog")
219
- tab_hotdogs.title("Hot Dog? Or Not?")
220
 
221
  if st.session_state.image is None:
222
  st.info("Please upload an image first.")
 
94
  #g_logger.warning("warning message")
95
 
96
  # Streamlit app
97
+ tab_inference, tab_hotdogs, tab_map, tab_coords, tab_log, tab_gallery = \
98
+ st.tabs(["Cetecean classifier", "Hotdog classifier", "Map", "*:gray[Dev:coordinates]*", "Log", "Beautiful cetaceans"])
99
  st.session_state.tab_log = tab_log
100
 
101
 
 
118
  with tab_map:
119
  # visual structure: a couple of toggles at the top, then the map inlcuding a
120
  # dropdown for tileset selection.
121
+ sw_map.add_header_text()
122
  tab_map_ui_cols = st.columns(2)
123
  with tab_map_ui_cols[0]:
124
  show_db_points = st.toggle("Show Points from DB", True)
 
147
 
148
 
149
 
150
+ with tab_coords:
151
  # the goal of this tab is to allow selection of the new obsvation's location by map click/adjust.
152
+ st.markdown("Coming later! :construction:")
153
+ st.markdown(
154
+ f"""*The goal is to allow interactive definition for the coordinates of a new
155
+ observation, by click/drag points on the map.*""")
156
+
157
 
158
  st.write("Click on the map to capture a location.")
159
  #m = folium.Map(location=visp_loc, zoom_start=7)
 
185
  tab_log.info(f"{st.session_state.observations}")
186
 
187
  df = pd.DataFrame(submitted_data, index=[0])
188
+ with tab_coords:
189
  st.table(df)
190
 
191
 
 
197
  # - these species are shown
198
  # - the user can override the species prediction using the dropdown
199
  # - an observation is uploaded if the user chooses.
200
+ tab_inference.markdown("""
201
+ *Run classifer to identify the species of cetean on the uploaded image.
202
+ Once inference is complete, the top three predictions are shown.
203
+ You can override the prediction by selecting a species from the dropdown.*""")
204
 
205
  if tab_inference.button("Identify with cetacean classifier"):
206
  #pipe = pipeline("image-classification", model="Saving-Willy/cetacean-classifier", trust_remote_code=True)
207
  cetacean_classifier = AutoModelForImageClassification.from_pretrained("Saving-Willy/cetacean-classifier",
208
  revision=classifier_revision,
209
  trust_remote_code=True)
210
+
211
 
212
  if st.session_state.images is None:
213
  # TODO: cleaner design to disable the button until data input done?
 
222
  # purposes, an hotdog image classifier) which will be run locally.
223
  # - this model predicts if the image is a hotdog or not, and returns probabilities
224
  # - the input image is the same as for the ceteacean classifier - defined in the sidebar
225
+ tab_hotdogs.title("Hot Dog? Or Not?")
226
+ tab_hotdogs.write("""
227
+ *Run alternative classifer on input images. Here we are using
228
+ a binary classifier - hotdog or not - from
229
+ huggingface.co/julien-c/hotdog-not-hotdog.*""")
230
 
231
  if tab_hotdogs.button("Get Hotdog Prediction"):
232
 
233
  pipeline_hot_dog = pipeline(task="image-classification", model="julien-c/hotdog-not-hotdog")
 
234
 
235
  if st.session_state.image is None:
236
  st.info("Please upload an image first.")
src/maps/obs_map.py CHANGED
@@ -189,4 +189,11 @@ def present_obs_map(dataset_id:str = "Saving-Willy/Happywhale-kaggle",
189
  # this is just debug info --
190
  #st.info("[D]" + str(metadata.column_names))
191
 
192
- return st_data
 
 
 
 
 
 
 
 
189
  # this is just debug info --
190
  #st.info("[D]" + str(metadata.column_names))
191
 
192
+ return st_data
193
+
194
+
195
+ def add_header_text() -> None:
196
+ """
197
+ Add brief explainer text to the tab
198
+ """
199
+ st.write("A map showing the observations in the dataset, with markers colored by species.")
src/whale_gallery.py CHANGED
@@ -59,7 +59,11 @@ def render_whale_gallery(n_cols:int = 4) -> None:
59
  """,
60
  unsafe_allow_html=True,
61
  )
62
-
 
 
 
 
63
  cols = cycle(st.columns(n_cols))
64
  for ix in range(len(sw_wv.df_whale_img_ref)):
65
  img_name = sw_wv.df_whale_img_ref.iloc[ix].loc["WHALE_IMAGES"]
 
59
  """,
60
  unsafe_allow_html=True,
61
  )
62
+ _n = len(sw_wv.df_whale_img_ref)
63
+ st.markdown(
64
+ f"""*The {_n} classes of cetaceans that our classifier can identify.
65
+ The links provide more information about each species, from NOAA or
66
+ wikipedia.*""")
67
  cols = cycle(st.columns(n_cols))
68
  for ix in range(len(sw_wv.df_whale_img_ref)):
69
  img_name = sw_wv.df_whale_img_ref.iloc[ix].loc["WHALE_IMAGES"]
src/whale_viewer.py CHANGED
@@ -1,5 +1,7 @@
1
  from typing import List
2
  import streamlit as st
 
 
3
  from PIL import Image
4
  import pandas as pd
5
  import os
@@ -117,22 +119,20 @@ def format_whale_name(whale_class:str) -> str:
117
  return whale_name
118
 
119
 
120
- def display_whale(whale_classes:List[str], i:int, viewcontainer=None):
121
  """
122
  Display whale image and reference to the provided viewcontainer.
123
 
124
  Args:
125
  whale_classes (List[str]): A list of whale class names.
126
  i (int): The index of the whale class to display.
127
- viewcontainer: The container to display the whale information. If
128
- not provided, use the current streamlit context (works via
129
- 'with `container`' syntax)
130
 
131
  Returns:
132
  None
133
 
134
- TODO: how to find the object type of viewcontainer.? they are just "deltagenerators" but
135
- we want the result of the generator.. In any case, it works ok with either call signature.
136
  """
137
 
138
  if viewcontainer is None:
 
1
  from typing import List
2
  import streamlit as st
3
+ from streamlit.delta_generator import DeltaGenerator
4
+
5
  from PIL import Image
6
  import pandas as pd
7
  import os
 
119
  return whale_name
120
 
121
 
122
+ def display_whale(whale_classes:List[str], i:int, viewcontainer:DeltaGenerator=None) -> None:
123
  """
124
  Display whale image and reference to the provided viewcontainer.
125
 
126
  Args:
127
  whale_classes (List[str]): A list of whale class names.
128
  i (int): The index of the whale class to display.
129
+ viewcontainer (streamlit.delta_generator.DeltaGenerator): The container
130
+ to display the whale information. If not provided, use the current
131
+ streamlit context (works via 'with `container`' syntax)
132
 
133
  Returns:
134
  None
135
 
 
 
136
  """
137
 
138
  if viewcontainer is None:
tests/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Test report
2
+
3
+ - overall status: ![CI test build](https://github.com/sdsc-ordes/saving-willy/actions/workflows/python-pytest.yml/badge.svg)
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.