File size: 14,730 Bytes
603e09c
143e083
043ecd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7a83d46
 
 
 
 
 
 
8d0070f
 
 
 
 
 
 
 
 
 
 
 
 
603e09c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90fc0f4
 
3872c0b
90fc0f4
3872c0b
603e09c
90fc0f4
 
 
 
603e09c
 
1c01d92
 
57a1d31
1c01d92
 
 
57a1d31
 
1c01d92
 
90fc0f4
1c01d92
 
603e09c
 
1c01d92
 
603e09c
1c01d92
 
603e09c
 
1c01d92
 
05795de
 
1c01d92
05795de
 
 
 
 
 
 
 
 
 
 
90fc0f4
 
 
 
603e09c
 
05795de
 
 
 
 
 
 
badac0a
 
 
 
 
 
 
05795de
 
 
 
 
 
 
f8a5dc6
 
 
 
 
 
 
c15ff11
639e59b
c15ff11
 
 
 
 
90fc0f4
 
 
 
 
603e09c
 
05795de
 
 
 
 
 
 
 
 
 
 
 
 
 
c15ff11
 
 
 
 
 
 
90fc0f4
 
 
 
603e09c
 
1a73708
 
 
 
 
 
 
 
 
 
 
 
 
 
0164e89
 
1c01d92
 
 
 
 
 
 
 
 
 
 
 
 
83587df
 
 
 
 
 
1c01d92
 
 
83587df
 
f8a5dc6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05795de
 
 
 
 
 
 
de4ab0d
043ecd7
de4ab0d
043ecd7
 
 
 
 
 
 
 
 
 
 
 
 
de4ab0d
043ecd7
 
 
 
 
 
de4ab0d
7633dda
 
 
 
 
 
 
 
 
 
043ecd7
 
 
de4ab0d
043ecd7
 
 
 
 
 
 
de4ab0d
043ecd7
 
 
 
 
 
de4ab0d
043ecd7
83587df
043ecd7
 
603e09c
 
 
 
 
 
83587df
603e09c
 
 
 
 
 
 
 
83587df
603e09c
 
 
 
 
 
 
 
83587df
603e09c
 
 
 
 
 
 
83587df
603e09c
 
 
 
 
 
 
 
83587df
603e09c
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
from unittest.mock import patch

import pytest
import torch

# --- Fast tests (no model loading required) ---


def test_app_imports_without_model_load():
    """Importing app should not trigger model download."""
    import app

    assert hasattr(app, "translate")
    assert hasattr(app, "_load_tokenizer")
    assert hasattr(app, "_load_model")


def test_app_has_main_function():
    """main() should be callable (UI construction)."""
    import app

    assert callable(app.main)


def test_translate_accepts_generation_params():
    """translate() signature must accept generation parameters."""
    import inspect

    import app

    sig = inspect.signature(app.translate)
    params = list(sig.parameters.keys())
    assert "max_new_tokens" in params
    assert "num_beams" in params
    assert "temperature" in params


def test_translate_signature_defaults():
    """translate() should have correct default values for generation parameters."""
    import inspect

    import app

    sig = inspect.signature(app.translate)
    assert sig.parameters["max_new_tokens"].default == 512
    assert sig.parameters["num_beams"].default == 1
    assert sig.parameters["temperature"].default == 1.0


def test_get_device_returns_cpu_when_no_cuda():
    """_get_device() should return CPU when CUDA is not available."""
    import app

    with patch("app.torch.cuda.is_available", return_value=False):
        with pytest.warns(UserWarning):
            device = app._get_device()
    assert device.type == "cpu"


def test_get_device_warns_on_cpu():
    """_get_device() should emit a UserWarning when falling back to CPU."""
    import app

    with patch("app.torch.cuda.is_available", return_value=False):
        with pytest.warns(UserWarning, match="No GPU available"):
            app._get_device()


# --- UI component tests ---


@pytest.fixture
def demo():
    import app

    return app._build_demo()


def test_demo_has_no_tabs(demo):
    """Redesigned UI should have no Tab components."""
    tabs = [b for b in demo.blocks.values() if type(b).__name__ == "Tab"]
    assert len(tabs) == 0, f"Expected no tabs, found {len(tabs)}"


def test_demo_has_no_sliders(demo):
    """Redesigned UI should have no Slider components."""
    sliders = [b for b in demo.blocks.values() if type(b).__name__ == "Slider"]
    assert len(sliders) == 0, f"Expected no sliders, found {len(sliders)}"


def test_demo_has_two_interactive_dropdowns(demo):
    """UI should have two interactive language dropdowns."""
    dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
    assert len(dropdowns) == 2, f"Expected 2 dropdowns, found {len(dropdowns)}"
    interactive = [d for d in dropdowns if d.interactive is not False]
    assert len(interactive) == 2, f"Expected 2 interactive dropdowns, found {len(interactive)}"


def test_source_dropdown_default_is_english(demo):
    """Source dropdown should default to English (en)."""
    dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
    english = [d for d in dropdowns if d.value == "English (en)"]
    assert len(english) == 1, "Expected one dropdown defaulting to 'English (en)'"


def test_target_dropdown_default_is_french(demo):
    """Target dropdown should default to French (fr)."""
    dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
    french = [d for d in dropdowns if d.value == "French (fr)"]
    assert len(french) == 1, "Expected one dropdown defaulting to 'French (fr)'"


def test_both_dropdowns_filterable(demo):
    """Both language dropdowns should be filterable/searchable."""
    dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
    interactive = [d for d in dropdowns if d.interactive is not False]
    assert all(d.filterable is True for d in interactive), "Both dropdowns should be filterable"


def test_dropdown_choices_include_locale_codes(demo):
    """Dropdown choices should include locale codes like 'French (fr)'."""
    dropdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Dropdown"]
    interactive = [d for d in dropdowns if d.interactive is not False]
    # Gradio stores choices as (label, value) tuples
    labels = [c[0] if isinstance(c, tuple) else c for c in interactive[0].choices]
    assert all("(" in label and ")" in label for label in labels), f"Expected locale codes in choices: {labels}"


def test_demo_has_two_textboxes(demo):
    """UI should have input and output textboxes."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    assert len(textboxes) == 2, f"Expected 2 textboxes, found {len(textboxes)}"


def test_input_textbox_height(demo):
    """Input textbox should use lines=6."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    interactive = [t for t in textboxes if t.interactive is not False]
    assert interactive[0].lines == 6, f"Expected lines=6, got {interactive[0].lines}"


def test_input_textbox_max_length(demo):
    """Input textbox should enforce 2000 character limit."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    interactive = [t for t in textboxes if t.interactive is not False]
    assert interactive[0].max_length == 2000, f"Expected max_length=2000, got {interactive[0].max_length}"


def test_input_textbox_has_no_placeholder(demo):
    """Input textbox should have no placeholder text."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    interactive = [t for t in textboxes if t.interactive is not False]
    assert interactive[0].placeholder is None, f"Expected no placeholder, got {interactive[0].placeholder!r}"


def test_input_textbox_autofocus(demo):
    """Input textbox should autofocus so the cursor lands there on page load."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    interactive = [t for t in textboxes if t.interactive is not False]
    assert interactive[0].autofocus is True, f"Expected autofocus=True, got {interactive[0].autofocus!r}"


def test_input_textbox_has_no_buttons(demo):
    """Input textbox should expose no toolbar buttons (gradio accepts invalid button values silently)."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    interactive = [t for t in textboxes if t.interactive is not False]
    assert interactive[0].buttons == [], f"Expected no buttons, got {interactive[0].buttons!r}"


def test_output_textbox_is_non_interactive(demo):
    """Output textbox should be non-interactive."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    output = [t for t in textboxes if t.interactive is False]
    assert len(output) == 1, "Expected exactly one non-interactive textbox"


def test_output_textbox_height(demo):
    """Output textbox should use lines=6."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    non_interactive = [t for t in textboxes if t.interactive is False]
    assert non_interactive[0].lines == 6, f"Expected lines=6, got {non_interactive[0].lines}"


def test_output_placeholder(demo):
    """Output textbox should have 'Translation' as placeholder."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    output = [t for t in textboxes if t.interactive is False]
    assert output[0].placeholder == "Translation"


def test_output_textbox_has_copy_button(demo):
    """Output textbox should expose a copy button."""
    textboxes = [b for b in demo.blocks.values() if type(b).__name__ == "Textbox"]
    output = [t for t in textboxes if t.interactive is False]
    assert output[0].buttons == ["copy"], f"Expected ['copy'], got {output[0].buttons!r}"


def test_demo_has_translate_button(demo):
    """UI should have a Translate button."""
    buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button"]
    assert any(b.value == "Translate" for b in buttons), "Expected a 'Translate' button"


def test_translate_button_outside_columns(demo):
    """Translate button should not be inside either Column."""
    buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button" and b.value == "Translate"]
    assert len(buttons) == 1
    node = getattr(buttons[0], "parent", None)
    while node is not None:
        assert type(node).__name__ != "Column", "Translate button should not be inside a Column"
        node = getattr(node, "parent", None)


def test_demo_has_no_html_elements(demo):
    """UI should have no HTML elements (hint/char count removed)."""
    html_blocks = [b for b in demo.blocks.values() if type(b).__name__ == "HTML"]
    assert len(html_blocks) == 0, f"Expected no HTML blocks, found {len(html_blocks)}"


def test_demo_has_swap_button(demo):
    """UI should have a swap button."""
    buttons = [b for b in demo.blocks.values() if type(b).__name__ == "Button"]
    swap = [b for b in buttons if "⇄" in str(b.value)]
    assert len(swap) == 1, "Expected one swap button"


def test_swap_handler_wired(demo):
    """Swap button should have a click handler with 4 inputs and 4 outputs."""
    swap_fns = [fn for fn in demo.fns.values() if len(fn.inputs) == 4 and len(fn.outputs) == 4]
    assert len(swap_fns) >= 1, "Expected a handler with 4 inputs and 4 outputs (swap handler)"


def test_translate_handlers_wire_text_and_language(demo):
    """Translate click and submit handlers should wire input text and target language."""
    translate_fns = [fn for fn in demo.fns.values() if [type(i).__name__ for i in fn.inputs] == ["Textbox", "Dropdown"]]
    assert len(translate_fns) == 2, f"Expected 2 translate handlers (click + submit), found {len(translate_fns)}"


def test_all_handlers_wired(demo):
    """UI should have exactly 3 click/submit handlers: translate click, translate submit, swap."""
    assert len(demo.fns) == 3, f"Expected 3 handlers, found {len(demo.fns)}"


def test_translate_endpoint_has_stable_api_name(demo):
    """The lean submit handler (text + language -> string) should expose a stable 'translate' API endpoint."""
    api_fns = [fn for fn in demo.fns.values() if getattr(fn, "api_name", None) == "translate"]
    assert len(api_fns) == 1, "Expected exactly one handler with api_name='translate'"
    fn = api_fns[0]
    assert [type(i).__name__ for i in fn.inputs] == ["Textbox", "Dropdown"]
    assert [type(o).__name__ for o in fn.outputs] == ["Textbox"]


def test_only_translate_endpoint_is_public(demo):
    """UI-only handlers (swap, button loading) should be private; only /translate is a public API endpoint."""
    named = list(demo.get_api_info()["named_endpoints"].keys())
    assert named == ["/translate"], f"Expected only ['/translate'] exposed, found {named}"


def test_no_title(demo):
    """UI should not have an H1 title."""
    markdowns = [b for b in demo.blocks.values() if type(b).__name__ == "Markdown"]
    for md in markdowns:
        assert not md.value.startswith("# "), f"Found unexpected title: {md.value}"


# --- Slow tests (require CUDA + model download) ---

gpu_available = torch.cuda.is_available()


@pytest.fixture(scope="module")
def loaded_app():
    import app

    # Force model/tokenizer loading
    app._load_tokenizer()
    app._load_model()
    return app


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_name_to_code_matches_language_names(loaded_app):
    name_to_code, language_names = loaded_app._build_language_mappings()
    assert set(name_to_code.keys()) == set(language_names)


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_language_names_sorted_by_region(loaded_app):
    """Language names should be sorted by region, then alphabetically within each region."""
    from langmap.langid_mapping import langid_to_language

    name_to_code, language_names = loaded_app._build_language_mappings()
    expected = sorted(
        language_names,
        key=lambda n: (langid_to_language[name_to_code[n]]["region"], n),
    )
    assert language_names == expected


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_all_codes_are_bcp47_tokens(loaded_app):
    name_to_code, _ = loaded_app._build_language_mappings()
    for name, code in name_to_code.items():
        assert code.startswith("<2") and code.endswith(">"), f"Invalid code {code} for {name}"


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_unsupported_language(loaded_app):
    with pytest.raises(ValueError, match="Unsupported language"):
        loaded_app.translate("hello", "FakeLanguage")


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_returns_string(loaded_app):
    result = loaded_app.translate("Hello", "French (fr)")
    assert isinstance(result, str)
    assert len(result) > 0


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_with_beam_search(loaded_app):
    """Translation with beam search (num_beams=4) should return a string."""
    result = loaded_app.translate("Hello", "French (fr)", num_beams=4)
    assert isinstance(result, str)
    assert len(result) > 0


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_with_custom_temperature(loaded_app):
    """Translation with custom temperature should return a string."""
    result = loaded_app.translate("Hello", "French (fr)", temperature=0.5)
    assert isinstance(result, str)
    assert len(result) > 0


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_with_custom_max_tokens(loaded_app):
    """Translation with low max_new_tokens should return a short string."""
    result = loaded_app.translate("Hello", "French (fr)", max_new_tokens=10)
    assert isinstance(result, str)


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_empty_string(loaded_app):
    """Translating an empty string should not crash."""
    result = loaded_app.translate("", "French (fr)")
    assert isinstance(result, str)


@pytest.mark.slow
@pytest.mark.skipif(not gpu_available, reason="Requires CUDA")
def test_translate_beam_search_ignores_temperature(loaded_app):
    """When beam search is active with non-default temperature, gr.Info should be called."""
    with patch("app.gr.Info") as mock_info:
        result = loaded_app.translate("Hello", "French (fr)", num_beams=4, temperature=0.5)
        mock_info.assert_called_once()
    assert isinstance(result, str)