Spaces:
Sleeping
Sleeping
from typing import Protocol, runtime_checkable | |
from pathlib import Path | |
from io import BytesIO | |
from PIL import Image | |
import pytest | |
from unittest.mock import MagicMock, patch | |
from streamlit.testing.v1 import AppTest | |
# tests for apptest/demo_multifile_upload | |
# - the functionality in the test harness is a file_uploader that is configured | |
# for multi-file input; and uses a callback to buffer the files into session state. | |
# - the handling of individual files includes extracting metadata from the files | |
# - a text_area is created for each file, to display the metadata extracted; | |
# this deviates from the presentation in the real app, but the extracted info | |
# is the same (here we put it all in text which is far easier to validate using AppTest) | |
# - the demo also has the author email input | |
# zero test: no inputs -> empty session state | |
# (or maybe even non-existent session state; for file_uploader we are not | |
# allowed to initialise the keyed variable, st borks) | |
# many test: list of >=2 inputs -> session state with 2 files | |
# for expectations | |
from input.input_handling import spoof_metadata, load_debug_autopopulate | |
from input.input_validator import get_image_datetime, get_image_latlon | |
class UploadedFile(Protocol): | |
name: str | |
size: int | |
type: str | |
#RANDO: str | |
_file_urls: list | |
def getvalue(self) -> bytes: ... | |
def read(self) -> bytes: ... | |
class MockUploadedFile(BytesIO): | |
def __init__(self, | |
initial_bytes: bytes, | |
*, | |
name: str, | |
size: int, | |
type: str): | |
super().__init__(initial_bytes) | |
self.name = name # Simulate a filename | |
self.size = size # Simulate file size | |
self.type = type # Simulate MIME type | |
self.file_id = None | |
def mock_uploadedFile(): | |
def _mock_uploadedFile(name: str, size: int, type: str): | |
test_data = b'test data' | |
# now load some real data, if fname exists | |
base = Path(__file__).parent.parent | |
fname = Path(base / f"tests/data/{name}") | |
if fname.exists(): | |
with open(fname, 'rb') as f: | |
#test_data = BytesIO(f.read()) | |
test_data = f.read() | |
else: | |
#print(f"[DDDD] {name}, {size}, {type} not found") | |
raise FileNotFoundError(f"file {fname} not found ({name}, {size}, {type})") | |
return MockUploadedFile( | |
test_data, name=name, size=size, type=type,) | |
return _mock_uploadedFile | |
def mock_uploadedFileNoRealData(): | |
class MockGUIClassFakeData(MagicMock): | |
def __init__(self, *args, **kwargs): | |
super().__init__(*args, **kwargs) | |
name = kwargs.get('fname', 'image2.jpg') | |
size = kwargs.get('size', 123456) | |
type = kwargs.get('type', 'image/jpeg') | |
self.bytes_io = MockUploadedFile( | |
b"test data", name=name, size=size, type=type) | |
self.get_data = MagicMock(return_value=self.bytes_io) | |
# it seems unclear to me which member attributes get set by the MockUploadedFile constructor | |
# - for some reason, size and type get set, but name does not, and results in | |
# <MockGUIClass name='mock.name' id='<12345>'>. | |
# so let's sjust explicitly set all the relevant attributes here. | |
self.name = name | |
self.size = size | |
self.type = type | |
return MockGUIClassFakeData | |
def mock_uploadedFile_List(mock_uploadedFileNoRealData): | |
def create_list_of_mocks(num_files=3, **kwargs): | |
return [mock_uploadedFileNoRealData(**kwargs) for _ in range(num_files)] | |
return create_list_of_mocks | |
def mock_uploadedFile_List_ImageData(mock_uploadedFile): | |
def create_list_of_mocks_realdata(num_files=3, **kwargs): | |
print(f"[D] [mock_uploadedFile_List_Img-internal] num_files: {num_files}") | |
data = [ | |
{"name": "cakes.jpg", "size": 1234, "type": "image/jpeg"}, | |
{"name": "cakes_no_exif_datetime.jpg", "size": 12345, "type": "image/jpeg"}, | |
{"name": "cakes_no_exif_gps.jpg", "size": 123456, "type": "image/jpeg"}, | |
] | |
_the_files = [] | |
for i in range(num_files): | |
_the_files.append( mock_uploadedFile(**data[i])) | |
print(f"========== finished init of {num_files} mock_uploaded files | {len(_the_files)} ==========") | |
return _the_files | |
#return [mock_uploadedFile(**kwargs) for _ in range(num_files)] | |
return create_list_of_mocks_realdata | |
# simple tests on the author email input via AppTest | |
# - empty input should propagate to session state | |
# - invalid email should trigger an error | |
def test_no_input_no_interaction(): | |
with patch.dict(spoof_metadata, {"author_email": None}): | |
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run() | |
assert at.session_state.observations == {} | |
assert at.session_state.input_author_email == None | |
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run() | |
assert at.session_state.observations == {} | |
dbg = load_debug_autopopulate() | |
if dbg: # autopopulated | |
assert at.session_state.input_author_email == spoof_metadata.get("author_email") | |
else: # should be empty, the user has to fill it in | |
assert at.session_state.input_author_email == "" | |
def test_bad_email(): | |
with patch.dict(spoof_metadata, {"author_email": "notanemail"}): | |
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run() | |
assert at.session_state.input_author_email == "notanemail" | |
assert at.error[0].value == "Please enter a valid email address." | |
# test when we load real data files, with all properties as per real app | |
# - if files loaded correctly and metadata is extracted correctly, we should see the | |
# the data in both the session state and in the visual elements. | |
def test_mockupload_list_realdata(mock_file_rv: MagicMock, mock_uploadedFile_List_ImageData): | |
#def test_mockupload_list(mock_file_uploader_rtn: MagicMock, mock_uploadedFile_List): | |
num_files = 3 | |
PRINT_PROPS = False | |
# Create a list of n mock files | |
mock_files = mock_uploadedFile_List_ImageData(num_files=num_files) | |
# Set the return value of the mocked file_uploader to the list of mock files | |
mock_file_rv.return_value = mock_files | |
# Run the Streamlit app | |
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run() | |
# put the mocked file_upload into session state, as if it were the result of a file upload, with the key 'file_uploader_data' | |
at.session_state["file_uploader_data"] = mock_files | |
#print(f"[I] session state: {at.session_state}") | |
#print(f"[I] uploaded files: {at.session_state.file_uploader_data}") | |
if PRINT_PROPS: | |
print(f"[I] uploaded files: ({len(at.session_state.file_uploader_data)}) {at.session_state.file_uploader_data}") | |
for _f in at.session_state.file_uploader_data: | |
#print(f"\t[I] props: {dir(_f)}") | |
print(f" [I] name: {_f.name}") | |
print(f"\t[I] size: {_f.size}") | |
print(f"\t[I] type: {_f.type}") | |
# lets make an image from the data | |
im = Image.open(_f) | |
# lets see what metadata we can get to. | |
dt = get_image_datetime(_f) | |
print(f"\t[I] datetime: {dt}") | |
lat, lon = get_image_latlon(_f) | |
print(f"\t[I] lat, lon: {lat}, {lon}") | |
# we expect to get the following info from the files | |
# file1: | |
# datetime: 2024:10:24 15:59:45 | |
# lat, lon: 46.51860277777778, 6.562075 | |
# file2: | |
# datetime: None | |
# lat, lon: 46.51860277777778, 6.562075 | |
# let's run assertions on the backend data (session_state) | |
# and then on the front end too (visual elements) | |
f1 = at.session_state.file_uploader_data[0] | |
f2 = at.session_state.file_uploader_data[1] | |
assert get_image_datetime(f1) == "2024:10:24 15:59:45" | |
assert get_image_datetime(f2) == None | |
# use a tolerance of 1e-6, assert that the lat, lon is close to 46.5186 | |
assert abs(get_image_latlon(f1)[0] - 46.51860277777778) < 1e-6 | |
assert abs(get_image_latlon(f1)[1] - 6.562075) < 1e-6 | |
assert abs(get_image_latlon(f2)[0] - 46.51860277777778) < 1e-6 | |
assert abs(get_image_latlon(f2)[1] - 6.562075) < 1e-6 | |
# need to run the script top-to-bottom to get the text_area elements | |
# since they are dynamically created. | |
at.run() | |
# since we uplaoded num_files files, hopefully we get num_files text areas | |
assert len(at.text_area) == num_files | |
# expecting | |
exp0 = "index: 0, name: cakes.jpg, datetime: 2024:10:24 15:59:45, lat: 46.51860277777778, lon:6.562075" | |
exp1 = "index: 1, name: cakes_no_exif_datetime.jpg, datetime: None, lat: 46.51860277777778, lon:6.562075" | |
exp2 = "index: 2, name: cakes_no_exif_gps.jpg, datetime: 2024:10:24 15:59:45, lat: None, lon:None" | |
assert at.text_area[0].value == exp0 | |
assert at.text_area[1].value == exp1 | |
if num_files >= 1: | |
assert at.text_area(key='metadata_0').value == exp0 | |
if num_files >= 2: | |
assert at.text_area(key='metadata_1').value == exp1 | |
if num_files >= 3: | |
assert at.text_area(key='metadata_2').value == exp2 | |
# {"fname": "cakes.jpg", "size": 1234, "type": "image/jpeg"}, | |
# {"fname": "cakes_no_exif_datetime.jpg", "size": 12345, "type": "image/jpeg"}, | |
# {"fname": "cakes_no_exif_gps.jpg", "size": 123456, "type": "image/jpeg"}, | |
#] | |
# Verify the behavior in your app | |
assert len(at.session_state.file_uploader_data) == num_files | |
assert at.session_state.file_uploader_data[0].size == 1234 # Check properties of the first file | |
assert at.session_state.file_uploader_data[1].name == "cakes_no_exif_datetime.jpg" | |
# this test was a stepping stone; when I was mocking files that didn't have any real data | |
# - it helped to explore how properties should be set in the mock object and generator funcs. | |
def test_mockupload_list(mock_file_uploader_rtn: MagicMock, mock_uploadedFile_List): | |
# Create a list of 2 mock files | |
mock_files = mock_uploadedFile_List(num_files=2, fname="test.jpg", size=100, type="image/jpeg") | |
# Set the return value of the mocked file_uploader to the list of mock files | |
mock_file_uploader_rtn.return_value = mock_files | |
# Run the Streamlit app | |
at = AppTest.from_file("src/apptest/demo_multifile_upload.py").run() | |
# put the mocked file_upload into session state, as if it were the result of a file upload, with the key 'file_uploader_data' | |
at.session_state["file_uploader_data"] = mock_files | |
#print(f"[I] session state: {at.session_state}") | |
#print(f"[I] uploaded files: {at.session_state.file_uploader_data}") | |
if 1: | |
print(f"[I] uploaded files: {at.session_state.file_uploader_data}") | |
for _f in at.session_state.file_uploader_data: | |
print(f"[I] props: {dir(_f)}") | |
print(f"[I] name: {_f.name}") | |
print(f"[I] size: {_f.size}") | |
print(f"[I] type: {_f.type}") | |
print(f"[I] data : {type(_f)} | {type(_f.return_value)} | {_f}") | |
# lets make an image from it. | |
#im = Image.open(_f) | |
# Verify behavior in the app | |
assert len(at.session_state.file_uploader_data) == 2 | |
assert at.session_state.file_uploader_data[0].size == 100 # Check properties of the first file | |
assert at.session_state.file_uploader_data[1].name == "test.jpg" # Check properties of the second file | |