11
22import json
33import os
4- import tempfile
54from pathlib import Path
6- from unittest .mock import MagicMock , patch
5+ from unittest .mock import MagicMock
76
87import numpy as np
98import pytest
1413
1514@pytest .fixture
1615def dummy_image_path (tmp_path ):
17- """Create a 224x224 dummy RGB image for testing — no real leaf needed."""
16+ """224x224 dummy RGB image — no real leaf needed."""
1817 img = Image .fromarray (
1918 np .random .randint (0 , 255 , (224 , 224 , 3 ), dtype = np .uint8 )
2019 )
21- img_path = tmp_path / "test_leaf.jpg"
22- img .save (img_path )
23- return str (img_path )
20+ path = tmp_path / "test_leaf.jpg"
21+ img .save (path )
22+ return str (path )
2423
2524
2625@pytest .fixture
@@ -39,33 +38,33 @@ def dummy_class_names():
3938
4039@pytest .fixture
4140def mock_model (dummy_class_names ):
42- """Mock Keras model that returns a valid softmax-like output (sums to 1) ."""
41+ """Mock Keras model — returns valid softmax output. No TF needed ."""
4342 model = MagicMock ()
44- num_classes = len (dummy_class_names )
45- raw = np .random .rand (1 , num_classes ).astype (np .float32 )
46- raw = raw / raw .sum () # normalise to sum to 1
43+ n = len (dummy_class_names )
44+ raw = np .random .rand (1 , n ).astype (np .float32 )
45+ raw = raw / raw .sum ()
4746 model .predict .return_value = raw
4847 return model
4948
5049
5150@pytest .fixture
5251def high_confidence_mock_model (dummy_class_names ):
53- """Mock model that always returns very high confidence on class 0."""
52+ """Mock model that always returns 99% confidence on class 0."""
5453 model = MagicMock ()
55- num_classes = len (dummy_class_names )
56- scores = np .zeros ((1 , num_classes ), dtype = np .float32 )
54+ n = len (dummy_class_names )
55+ scores = np .zeros ((1 , n ), dtype = np .float32 )
5756 scores [0 , 0 ] = 0.99
58- scores [0 , 1 :] = 0.01 / (num_classes - 1 )
57+ scores [0 , 1 :] = 0.01 / (n - 1 )
5958 model .predict .return_value = scores
6059 return model
6160
6261
6362@pytest .fixture
6463def low_confidence_mock_model (dummy_class_names ):
65- """Mock model that returns nearly uniform (low confidence) output."""
64+ """Mock model that returns uniform (low confidence) output."""
6665 model = MagicMock ()
67- num_classes = len (dummy_class_names )
68- scores = np .full ((1 , num_classes ), 1.0 / num_classes , dtype = np .float32 )
66+ n = len (dummy_class_names )
67+ scores = np .full ((1 , n ), 1.0 / n , dtype = np .float32 )
6968 model .predict .return_value = scores
7069 return model
7170
@@ -76,9 +75,7 @@ class TestPreprocessImage:
7675 def test_output_shape (self , dummy_image_path ):
7776 from src .predict import preprocess_image
7877 result = preprocess_image (dummy_image_path )
79- assert result .shape == (1 , 224 , 224 , 3 ), (
80- f"Expected (1, 224, 224, 3), got { result .shape } "
81- )
78+ assert result .shape == (1 , 224 , 224 , 3 )
8279
8380 def test_output_dtype_is_float32 (self , dummy_image_path ):
8481 from src .predict import preprocess_image
@@ -92,40 +89,38 @@ def test_pixel_values_in_0_255_range(self, dummy_image_path):
9289 assert result .min () >= 0.0
9390 assert result .max () <= 255.0
9491
95- def test_handles_non_rgb_image (self , tmp_path ):
96- """RGBA images should be converted to RGB without crashing."""
92+ def test_handles_rgba_image (self , tmp_path ):
93+ """RGBA images must be converted to RGB without crashing."""
9794 from src .predict import preprocess_image
9895 img = Image .fromarray (
9996 np .random .randint (0 , 255 , (100 , 100 , 4 ), dtype = np .uint8 ), mode = "RGBA"
10097 )
101- img_path = str (tmp_path / "rgba.png" )
102- img .save (img_path )
103- result = preprocess_image (img_path )
98+ path = str (tmp_path / "rgba.png" )
99+ img .save (path )
100+ result = preprocess_image (path )
104101 assert result .shape == (1 , 224 , 224 , 3 )
105102
106103
107104# ── predict ────────────────────────────────────────────────────────────────────
108105
109106class TestPredict :
110- def test_returns_dict_with_required_keys (self , dummy_image_path , mock_model , dummy_class_names ):
107+ def test_returns_required_keys (self , dummy_image_path , mock_model , dummy_class_names ):
111108 from src .predict import predict
112109 result = predict (dummy_image_path , model = mock_model , class_names = dummy_class_names )
113110 for key in ("predicted_class" , "confidence" , "top3" , "all_scores" , "low_confidence" ):
114- assert key in result , f"Missing key: { key } "
111+ assert key in result
115112
116113 def test_predicted_class_is_valid_or_unknown (self , dummy_image_path , mock_model , dummy_class_names ):
117114 from src .predict import predict
118115 result = predict (dummy_image_path , model = mock_model , class_names = dummy_class_names )
119116 assert result ["predicted_class" ] in dummy_class_names or result ["predicted_class" ] == "Unknown"
120117
121- def test_confidence_between_0_and_1 (self , dummy_image_path , mock_model , dummy_class_names ):
118+ def test_confidence_in_range (self , dummy_image_path , mock_model , dummy_class_names ):
122119 from src .predict import predict
123120 result = predict (dummy_image_path , model = mock_model , class_names = dummy_class_names )
124- assert 0.0 <= result ["confidence" ] <= 1.0 , (
125- f"Confidence { result ['confidence' ]} out of [0, 1]"
126- )
121+ assert 0.0 <= result ["confidence" ] <= 1.0
127122
128- def test_top3_has_exactly_three_items (self , dummy_image_path , mock_model , dummy_class_names ):
123+ def test_top3_length (self , dummy_image_path , mock_model , dummy_class_names ):
129124 from src .predict import predict
130125 result = predict (dummy_image_path , model = mock_model , class_names = dummy_class_names )
131126 assert len (result ["top3" ]) == 3
@@ -134,7 +129,7 @@ def test_top3_sorted_descending(self, dummy_image_path, mock_model, dummy_class_
134129 from src .predict import predict
135130 result = predict (dummy_image_path , model = mock_model , class_names = dummy_class_names )
136131 confs = [item ["confidence" ] for item in result ["top3" ]]
137- assert confs == sorted (confs , reverse = True ), "Top-3 must be sorted highest → lowest"
132+ assert confs == sorted (confs , reverse = True )
138133
139134 def test_all_scores_has_all_classes (self , dummy_image_path , mock_model , dummy_class_names ):
140135 from src .predict import predict
@@ -147,8 +142,7 @@ def test_high_confidence_not_flagged(self, dummy_image_path, high_confidence_moc
147142 assert result ["low_confidence" ] is False
148143 assert result ["predicted_class" ] != "Unknown"
149144
150- def test_low_confidence_flagged_as_unknown (self , dummy_image_path , low_confidence_mock_model , dummy_class_names ):
151- """When confidence is below threshold, predicted_class must be 'Unknown'."""
145+ def test_low_confidence_returns_unknown (self , dummy_image_path , low_confidence_mock_model , dummy_class_names ):
152146 from src .predict import predict
153147 result = predict (dummy_image_path , model = low_confidence_mock_model , class_names = dummy_class_names )
154148 assert result ["low_confidence" ] is True
@@ -160,11 +154,10 @@ def test_missing_image_raises_error(self, mock_model, dummy_class_names):
160154 predict ("/nonexistent/path/leaf.jpg" , model = mock_model , class_names = dummy_class_names )
161155
162156
163- # ── model building (weights mocked to avoid 29MB download in CI) ──────── ──────
157+ # ── model building (no TF import at module level — lazy inside functions) ──────
164158
165159class TestModelBuilding :
166160 def test_model_output_shape (self ):
167- """FIX: use load_weights=False to skip ImageNet download in CI/tests."""
168161 from src .model import build_model
169162 model = build_model (num_classes = 8 , load_weights = False )
170163 assert model .output_shape == (None , 8 )
@@ -175,7 +168,6 @@ def test_model_input_shape(self):
175168 assert model .input_shape == (None , 224 , 224 , 3 )
176169
177170 def test_model_compiles_without_error (self ):
178- import tensorflow as tf
179171 from src .model import build_model
180172 model = build_model (num_classes = 8 , load_weights = False )
181173 model .compile (
@@ -188,7 +180,7 @@ def test_custom_num_classes(self):
188180 from src .model import build_model
189181 for n in [2 , 10 , 38 ]:
190182 model = build_model (num_classes = n , load_weights = False )
191- assert model .output_shape == (None , n ), f"Failed for num_classes= { n } "
183+ assert model .output_shape == (None , n )
192184
193185 def test_custom_input_shape (self ):
194186 from src .model import build_model
@@ -209,7 +201,6 @@ def test_missing_model_raises_file_not_found(self, tmp_path):
209201
210202 def test_missing_class_names_raises_file_not_found (self , tmp_path ):
211203 from src .predict import load_model_and_classes
212- # Create a dummy model file so the first check passes
213204 fake_model = tmp_path / "model.keras"
214205 fake_model .write_text ("fake" )
215206 with pytest .raises (FileNotFoundError , match = "Class names not found" ):
0 commit comments