Daryl Lim Claude Opus 4.6 commited on
Commit
603e09c
·
1 Parent(s): 99b797c

test: add 24 tests and improve test infrastructure

Browse files

Add 19 fast tests (signature defaults, device fallback, batch parsing
edge cases, output writing, guard clauses, UI component config) and
5 slow tests (beam search, temperature, max tokens, empty input,
beam+temperature conflict). Total: 25 → 49 tests.

Refactor existing tests to use pytest tmp_path fixture for automatic
cleanup, add shared demo fixture for UI tests, suppress uncaptured
UserWarning in device test, and modernize pytest.raises usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. tests/test_app.py +327 -54
tests/test_app.py CHANGED
@@ -1,6 +1,6 @@
1
  import csv
2
- import tempfile
3
  from pathlib import Path
 
4
 
5
  import pytest
6
  import torch
@@ -37,69 +37,158 @@ def test_translate_accepts_generation_params():
37
  assert "temperature" in params
38
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  # --- Batch file parsing helper tests ---
41
 
42
 
43
- def _make_txt_file(lines: list[str]) -> str:
44
- """Create a temp .txt file and return its path."""
45
- f = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
46
- f.write("\n".join(lines))
47
- f.close()
48
- return f.name
49
 
50
 
51
- def _make_csv_file(rows: list[dict]) -> str:
52
- """Create a temp .csv file and return its path."""
53
- f = tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="")
54
- writer = csv.DictWriter(f, fieldnames=rows[0].keys())
55
- writer.writeheader()
56
- writer.writerows(rows)
57
- f.close()
58
- return f.name
59
 
60
 
61
- def test_parse_txt_file():
62
  import app
63
 
64
- path = _make_txt_file(["Hello", "World", "Test"])
65
  texts, original_data = app._parse_input_file(path)
66
  assert texts == ["Hello", "World", "Test"]
67
  assert original_data is None
68
- Path(path).unlink()
69
 
70
 
71
- def test_parse_csv_file():
72
  import app
73
 
74
- path = _make_csv_file([{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}])
75
  texts, original_data = app._parse_input_file(path)
76
  assert texts == ["Hello", "World"]
77
  assert original_data is not None
78
  assert len(original_data) == 2
79
  assert original_data[0]["id"] == "1"
80
- Path(path).unlink()
81
 
82
 
83
- def test_parse_csv_missing_text_column():
84
  import app
85
 
86
- path = _make_csv_file([{"id": "1", "content": "Hello"}])
87
- try:
88
  app._parse_input_file(path)
89
- assert False, "Should have raised ValueError"
90
- except ValueError as e:
91
- assert "text" in str(e).lower()
92
- finally:
93
- Path(path).unlink()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  def test_write_txt_output():
97
  import app
98
 
99
  output_path = app._write_output_file(["Bonjour", "Monde"], None, ".txt")
100
- content = Path(output_path).read_text()
101
- assert content == "Bonjour\nMonde"
102
- Path(output_path).unlink()
 
 
103
 
104
 
105
  def test_write_csv_output():
@@ -107,13 +196,34 @@ def test_write_csv_output():
107
 
108
  original_data = [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}]
109
  output_path = app._write_output_file(["Bonjour", "Monde"], original_data, ".csv")
110
- with open(output_path, newline="") as f:
111
- reader = list(csv.DictReader(f))
112
- assert len(reader) == 2
113
- assert reader[0]["translation"] == "Bonjour"
114
- assert reader[0]["id"] == "1"
115
- assert reader[1]["translation"] == "Monde"
116
- Path(output_path).unlink()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
 
119
  def test_translate_batch_exists():
@@ -122,39 +232,155 @@ def test_translate_batch_exists():
122
  assert callable(app.translate_batch)
123
 
124
 
125
- def test_demo_has_sidebar_and_tabs():
126
- """Verify the demo contains the expected layout components."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  import app
128
 
129
- demo = app._build_demo()
130
- # Flatten all blocks in the demo
131
- blocks = demo.blocks
132
 
133
- # Check for key component types by looking at block values
134
- component_types = {type(b).__name__ for b in blocks.values()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  assert "Tab" in component_types, f"Expected Tab component, found: {component_types}"
136
  assert "Slider" in component_types, f"Expected Slider component, found: {component_types}"
137
  assert "Dropdown" in component_types, f"Expected Dropdown component, found: {component_types}"
138
 
139
 
140
- def test_demo_has_three_sliders():
141
  """Three sliders: max_new_tokens, num_beams, temperature."""
142
- import app
143
-
144
- demo = app._build_demo()
145
  sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"]
146
  assert len(sliders) == 3, f"Expected 3 sliders, found {len(sliders)}"
147
 
148
 
149
- def test_demo_has_two_tabs():
150
  """Two tabs: Single and Batch."""
151
- import app
152
-
153
- demo = app._build_demo()
154
  tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"]
155
  assert len(tabs) == 2, f"Expected 2 tabs, found {len(tabs)}"
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  # --- Slow tests (require CUDA + model download) ---
159
 
160
  gpu_available = torch.cuda.is_available()
@@ -205,3 +431,50 @@ def test_translate_returns_string(loaded_app):
205
  result = loaded_app.translate("Hello", "French")
206
  assert isinstance(result, str)
207
  assert len(result) > 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import csv
 
2
  from pathlib import Path
3
+ from unittest.mock import patch
4
 
5
  import pytest
6
  import torch
 
37
  assert "temperature" in params
38
 
39
 
40
+ # --- Task 1: Signature default + device tests ---
41
+
42
+
43
+ def test_translate_signature_defaults():
44
+ """translate() should have correct default values for generation parameters."""
45
+ import inspect
46
+
47
+ import app
48
+
49
+ sig = inspect.signature(app.translate)
50
+ assert sig.parameters["max_new_tokens"].default == 512
51
+ assert sig.parameters["num_beams"].default == 1
52
+ assert sig.parameters["temperature"].default == 1.0
53
+
54
+
55
+ def test_translate_batch_signature_defaults():
56
+ """translate_batch() should have correct default values for generation parameters."""
57
+ import inspect
58
+
59
+ import app
60
+
61
+ sig = inspect.signature(app.translate_batch)
62
+ assert sig.parameters["max_new_tokens"].default == 512
63
+ assert sig.parameters["num_beams"].default == 1
64
+ assert sig.parameters["temperature"].default == 1.0
65
+
66
+
67
+ def test_get_device_returns_cpu_when_no_cuda():
68
+ """_get_device() should return CPU when CUDA is not available."""
69
+ import app
70
+
71
+ with patch("app.torch.cuda.is_available", return_value=False):
72
+ with pytest.warns(UserWarning):
73
+ device = app._get_device()
74
+ assert device.type == "cpu"
75
+
76
+
77
+ def test_get_device_warns_on_cpu():
78
+ """_get_device() should emit a UserWarning when falling back to CPU."""
79
+ import app
80
+
81
+ with patch("app.torch.cuda.is_available", return_value=False):
82
+ with pytest.warns(UserWarning, match="No GPU available"):
83
+ app._get_device()
84
+
85
+
86
  # --- Batch file parsing helper tests ---
87
 
88
 
89
+ def _make_txt_file(tmp_dir: Path, lines: list[str]) -> str:
90
+ """Create a .txt file in tmp_dir and return its path."""
91
+ path = tmp_dir / "input.txt"
92
+ path.write_text("\n".join(lines))
93
+ return str(path)
 
94
 
95
 
96
+ def _make_csv_file(tmp_dir: Path, rows: list[dict]) -> str:
97
+ """Create a .csv file in tmp_dir and return its path."""
98
+ path = tmp_dir / "input.csv"
99
+ with open(path, "w", newline="") as f:
100
+ writer = csv.DictWriter(f, fieldnames=rows[0].keys())
101
+ writer.writeheader()
102
+ writer.writerows(rows)
103
+ return str(path)
104
 
105
 
106
+ def test_parse_txt_file(tmp_path):
107
  import app
108
 
109
+ path = _make_txt_file(tmp_path, ["Hello", "World", "Test"])
110
  texts, original_data = app._parse_input_file(path)
111
  assert texts == ["Hello", "World", "Test"]
112
  assert original_data is None
 
113
 
114
 
115
+ def test_parse_csv_file(tmp_path):
116
  import app
117
 
118
+ path = _make_csv_file(tmp_path, [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}])
119
  texts, original_data = app._parse_input_file(path)
120
  assert texts == ["Hello", "World"]
121
  assert original_data is not None
122
  assert len(original_data) == 2
123
  assert original_data[0]["id"] == "1"
 
124
 
125
 
126
+ def test_parse_csv_missing_text_column(tmp_path):
127
  import app
128
 
129
+ path = _make_csv_file(tmp_path, [{"id": "1", "content": "Hello"}])
130
+ with pytest.raises(ValueError, match="text"):
131
  app._parse_input_file(path)
132
+
133
+
134
+ # --- Task 2: Batch parsing edge cases ---
135
+
136
+
137
+ def test_parse_empty_txt_file(tmp_path):
138
+ """Empty .txt file should return ([], None)."""
139
+ import app
140
+
141
+ path = tmp_path / "empty.txt"
142
+ path.write_text("")
143
+ texts, original_data = app._parse_input_file(str(path))
144
+ assert texts == []
145
+ assert original_data is None
146
+
147
+
148
+ def test_parse_unsupported_extension(tmp_path):
149
+ """Unsupported file extension should raise ValueError."""
150
+ import app
151
+
152
+ path = tmp_path / "data.json"
153
+ path.write_text("{}")
154
+ with pytest.raises(ValueError, match="Unsupported file type"):
155
+ app._parse_input_file(str(path))
156
+
157
+
158
+ def test_parse_single_line_txt(tmp_path):
159
+ """Single-line .txt file should return one-element list."""
160
+ import app
161
+
162
+ path = _make_txt_file(tmp_path, ["Only one line"])
163
+ texts, original_data = app._parse_input_file(path)
164
+ assert texts == ["Only one line"]
165
+ assert original_data is None
166
+
167
+
168
+ def test_parse_single_row_csv(tmp_path):
169
+ """Single-row .csv file should return one-element list with metadata."""
170
+ import app
171
+
172
+ path = _make_csv_file(tmp_path, [{"id": "1", "text": "Single row"}])
173
+ texts, original_data = app._parse_input_file(path)
174
+ assert texts == ["Single row"]
175
+ assert original_data is not None
176
+ assert len(original_data) == 1
177
+ assert original_data[0]["id"] == "1"
178
+
179
+
180
+ # --- Write output tests ---
181
 
182
 
183
  def test_write_txt_output():
184
  import app
185
 
186
  output_path = app._write_output_file(["Bonjour", "Monde"], None, ".txt")
187
+ try:
188
+ content = Path(output_path).read_text()
189
+ assert content == "Bonjour\nMonde"
190
+ finally:
191
+ Path(output_path).unlink(missing_ok=True)
192
 
193
 
194
  def test_write_csv_output():
 
196
 
197
  original_data = [{"id": "1", "text": "Hello"}, {"id": "2", "text": "World"}]
198
  output_path = app._write_output_file(["Bonjour", "Monde"], original_data, ".csv")
199
+ try:
200
+ with open(output_path, newline="") as f:
201
+ reader = list(csv.DictReader(f))
202
+ assert len(reader) == 2
203
+ assert reader[0]["translation"] == "Bonjour"
204
+ assert reader[0]["id"] == "1"
205
+ assert reader[1]["translation"] == "Monde"
206
+ finally:
207
+ Path(output_path).unlink(missing_ok=True)
208
+
209
+
210
+ # --- Task 3: Write output edge case ---
211
+
212
+
213
+ def test_write_single_txt_output():
214
+ """Single translation output should have no extraneous newlines."""
215
+ import app
216
+
217
+ output_path = app._write_output_file(["Bonjour"], None, ".txt")
218
+ try:
219
+ content = Path(output_path).read_text()
220
+ assert content == "Bonjour"
221
+ assert "\n" not in content
222
+ finally:
223
+ Path(output_path).unlink(missing_ok=True)
224
+
225
+
226
+ # --- Batch function tests ---
227
 
228
 
229
  def test_translate_batch_exists():
 
232
  assert callable(app.translate_batch)
233
 
234
 
235
+ # --- Task 4: Batch function guard clauses ---
236
+
237
+
238
+ def test_translate_batch_none_file_raises_error():
239
+ """translate_batch() should raise gr.Error when file is None."""
240
+ import gradio as gr
241
+
242
+ import app
243
+
244
+ with pytest.raises(gr.Error, match="upload a file"):
245
+ app.translate_batch(None, "French")
246
+
247
+
248
+ def test_translate_batch_empty_file_raises_error(tmp_path):
249
+ """translate_batch() should raise gr.Error for an empty file."""
250
+ import gradio as gr
251
+
252
  import app
253
 
254
+ class FakeFile:
255
+ def __init__(self, name):
256
+ self.name = name
257
 
258
+ path = tmp_path / "empty.txt"
259
+ path.write_text("")
260
+ fake_file = FakeFile(str(path))
261
+ with pytest.raises(gr.Error, match="empty"):
262
+ app.translate_batch(fake_file, "French")
263
+
264
+
265
+ def test_translate_batch_accepts_generation_params():
266
+ """translate_batch() signature must accept generation parameters and progress."""
267
+ import inspect
268
+
269
+ import app
270
+
271
+ sig = inspect.signature(app.translate_batch)
272
+ params = list(sig.parameters.keys())
273
+ assert "max_new_tokens" in params
274
+ assert "num_beams" in params
275
+ assert "temperature" in params
276
+ assert "progress" in params
277
+
278
+
279
+ # --- UI component tests ---
280
+
281
+
282
+ @pytest.fixture
283
+ def demo():
284
+ import app
285
+
286
+ return app._build_demo()
287
+
288
+
289
+ def _find_slider_by_label(demo, label: str):
290
+ """Find a slider in the demo by its label."""
291
+ sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider" and b.label == label]
292
+ assert len(sliders) == 1, f"Expected 1 slider with label '{label}', found {len(sliders)}"
293
+ return sliders[0]
294
+
295
+
296
+ def test_demo_has_sidebar_and_tabs(demo):
297
+ """Verify the demo contains the expected layout components."""
298
+ component_types = {type(b).__name__ for b in demo.blocks.values()}
299
  assert "Tab" in component_types, f"Expected Tab component, found: {component_types}"
300
  assert "Slider" in component_types, f"Expected Slider component, found: {component_types}"
301
  assert "Dropdown" in component_types, f"Expected Dropdown component, found: {component_types}"
302
 
303
 
304
+ def test_demo_has_three_sliders(demo):
305
  """Three sliders: max_new_tokens, num_beams, temperature."""
 
 
 
306
  sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"]
307
  assert len(sliders) == 3, f"Expected 3 sliders, found {len(sliders)}"
308
 
309
 
310
+ def test_demo_has_two_tabs(demo):
311
  """Two tabs: Single and Batch."""
 
 
 
312
  tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"]
313
  assert len(tabs) == 2, f"Expected 2 tabs, found {len(tabs)}"
314
 
315
 
316
+ # --- Task 5: UI component detail tests ---
317
+
318
+
319
+ def test_slider_max_new_tokens_config(demo):
320
+ """Max new tokens slider should have correct config."""
321
+ slider = _find_slider_by_label(demo, "Max new tokens")
322
+ assert slider.minimum == 1
323
+ assert slider.maximum == 1024
324
+ assert slider.value == 512
325
+ assert slider.step == 1
326
+
327
+
328
+ def test_slider_num_beams_config(demo):
329
+ """Num beams slider should have correct config."""
330
+ slider = _find_slider_by_label(demo, "Num beams")
331
+ assert slider.minimum == 1
332
+ assert slider.maximum == 10
333
+ assert slider.value == 1
334
+ assert slider.step == 1
335
+
336
+
337
+ def test_slider_temperature_config(demo):
338
+ """Temperature slider should have correct config."""
339
+ slider = _find_slider_by_label(demo, "Temperature")
340
+ assert slider.minimum == 0.1
341
+ assert slider.maximum == 2.0
342
+ assert slider.value == 1.0
343
+ assert slider.step == 0.1
344
+
345
+
346
+ def test_dropdown_default_is_french(demo):
347
+ """Default dropdown value should be French."""
348
+ dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
349
+ assert len(dropdowns) >= 1
350
+ assert dropdowns[0].value == "French"
351
+
352
+
353
+ def test_file_upload_accepts_txt_and_csv(demo):
354
+ """File upload component should accept .txt and .csv files."""
355
+ file_components = [b for b in demo.blocks.values() if type(b).__name__ == "File"]
356
+ upload_files = [f for f in file_components if f.file_types is not None]
357
+ assert len(upload_files) >= 1
358
+ assert set(upload_files[0].file_types) == {".txt", ".csv"}
359
+
360
+
361
+ def test_batch_output_is_non_interactive(demo):
362
+ """Batch output file component should be non-interactive."""
363
+ file_components = [b for b in demo.blocks.values() if type(b).__name__ == "File"]
364
+ output_files = [f for f in file_components if f.interactive is False]
365
+ assert len(output_files) >= 1, "Expected at least one non-interactive File component"
366
+
367
+
368
+ def test_examples_use_tier1_languages(demo):
369
+ """All example languages should be in the tier 1 language mapping."""
370
+ from langmap.langid_mapping import langid_to_language
371
+
372
+ tier1_languages = set(langid_to_language.values())
373
+
374
+ # gr.Examples creates a Dataset component internally
375
+ datasets = [b for b in demo.blocks.values() if type(b).__name__ == "Dataset"]
376
+ assert len(datasets) >= 1, "Expected at least one Dataset (Examples) component"
377
+
378
+ for dataset in datasets:
379
+ for sample in dataset.samples:
380
+ language = sample[1] # [text, language]
381
+ assert language in tier1_languages, f"Example language '{language}' not in tier 1 mapping"
382
+
383
+
384
  # --- Slow tests (require CUDA + model download) ---
385
 
386
  gpu_available = torch.cuda.is_available()
 
431
  result = loaded_app.translate("Hello", "French")
432
  assert isinstance(result, str)
433
  assert len(result) > 0
434
+
435
+
436
+ # --- Task 6: Slow tests for non-default params ---
437
+
438
+
439
+ @pytest.mark.slow
440
+ @pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
441
+ def test_translate_with_beam_search(loaded_app):
442
+ """Translation with beam search (num_beams=4) should return a string."""
443
+ result = loaded_app.translate("Hello", "French", num_beams=4)
444
+ assert isinstance(result, str)
445
+ assert len(result) > 0
446
+
447
+
448
+ @pytest.mark.slow
449
+ @pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
450
+ def test_translate_with_custom_temperature(loaded_app):
451
+ """Translation with custom temperature should return a string."""
452
+ result = loaded_app.translate("Hello", "French", temperature=0.5)
453
+ assert isinstance(result, str)
454
+ assert len(result) > 0
455
+
456
+
457
+ @pytest.mark.slow
458
+ @pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
459
+ def test_translate_with_custom_max_tokens(loaded_app):
460
+ """Translation with low max_new_tokens should return a short string."""
461
+ result = loaded_app.translate("Hello", "French", max_new_tokens=10)
462
+ assert isinstance(result, str)
463
+
464
+
465
+ @pytest.mark.slow
466
+ @pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
467
+ def test_translate_empty_string(loaded_app):
468
+ """Translating an empty string should not crash."""
469
+ result = loaded_app.translate("", "French")
470
+ assert isinstance(result, str)
471
+
472
+
473
+ @pytest.mark.slow
474
+ @pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
475
+ def test_translate_beam_search_ignores_temperature(loaded_app):
476
+ """When beam search is active with non-default temperature, gr.Info should be called."""
477
+ with patch("app.gr.Info") as mock_info:
478
+ result = loaded_app.translate("Hello", "French", num_beams=4, temperature=0.5)
479
+ mock_info.assert_called_once()
480
+ assert isinstance(result, str)