File size: 21,760 Bytes
6921080
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ec4204
6921080
 
2ec4204
6921080
 
 
2ec4204
6921080
 
2ec4204
6921080
 
 
2ec4204
6921080
 
2ec4204
6921080
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
"""Unit tests for complex methods in app.py."""

import tempfile
from pathlib import Path
from typing import Any, Dict
from unittest.mock import Mock, patch


from yomitalk.app import PaperPodcastApp
from yomitalk.user_session import UserSession


class TestUpdateAudioButtonStateWithResumeCheck:
    """Test update_audio_button_state_with_resume_check method."""

    def setup_method(self):
        """Set up test fixtures."""
        self.app = PaperPodcastApp()
        self.user_session = UserSession("test_session")
        self.mock_request = Mock()
        self.mock_request.session_hash = "test_session_hash"

        # Base browser state
        self.browser_state: Dict[str, Any] = {
            "app_session_id": "test_session",
            "audio_generation_state": {
                "is_generating": False,
                "progress": 0.0,
                "status": "idle",
                "current_script": "",
                "generated_parts": [],
                "final_audio_path": None,
                "streaming_parts": [],
                "generation_id": None,
                "start_time": None,
                "estimated_total_parts": 1,
                "script_changed": False,
            },
            "user_settings": {},
            "ui_state": {},
        }

    def test_unchecked_checkbox_returns_disabled_state(self):
        """Test that unchecked checkbox returns disabled state with message."""
        result = self.app.update_audio_button_state_with_resume_check(checked=False, podcast_text="Some valid text", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is False
        assert result["variant"] == "secondary"
        assert "VOICEVOX利用規約に同意が必要です" in result["value"]

    def test_empty_text_returns_disabled_state(self):
        """Test that empty text returns disabled state with message."""
        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is False
        assert result["variant"] == "secondary"
        assert "トーク原稿が必要です" in result["value"]

    def test_whitespace_only_text_returns_disabled_state(self):
        """Test that whitespace-only text returns disabled state."""
        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="   \n\t  ", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is False
        assert result["variant"] == "secondary"
        assert "トーク原稿が必要です" in result["value"]

    def test_none_text_returns_disabled_state(self):
        """Test that None text returns disabled state."""
        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text=None, user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is False
        assert result["variant"] == "secondary"
        assert "トーク原稿が必要です" in result["value"]

    def test_new_script_returns_generate_button(self):
        """Test that new script returns generate button state."""
        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="New script content", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声を生成"

    def test_script_unchanged_with_streaming_parts_returns_resume_button(self):
        """Test that unchanged script with streaming parts returns resume button."""
        # Set up browser state with streaming parts
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["streaming_parts"] = ["part1.wav", "part2.wav"]

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声生成を再開"

    def test_script_unchanged_with_preparing_status_returns_resume_button(self):
        """Test that unchanged script with preparing status returns resume button."""
        # Set up browser state with preparing status
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["status"] = "preparing"

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声生成を再開"

    def test_script_unchanged_with_final_audio_returns_completed_button(self):
        """Test that unchanged script with final audio returns completed button."""
        # Set up browser state with final audio
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["final_audio_path"] = "/path/to/final.wav"

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is False
        assert result["variant"] == "secondary"
        assert result["value"] == "音声生成済み"

    def test_script_changed_after_completion_returns_generate_button(self):
        """Test that changed script after completion returns generate button."""
        # Set up browser state with final audio for old script
        self.browser_state["audio_generation_state"]["current_script"] = "Old script"
        self.browser_state["audio_generation_state"]["final_audio_path"] = "/path/to/final.wav"

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="New script content", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声を生成"

    def test_discovers_final_audio_on_disk_when_not_in_browser_state(self):
        """Test that method discovers final audio on disk when not in browser state."""
        # Set up browser state with matching script but no final audio
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["final_audio_path"] = None

        # Create a temporary directory and audio file
        with tempfile.TemporaryDirectory() as temp_dir:
            audio_file = Path(temp_dir) / "audio_final.wav"
            audio_file.write_text("fake audio content")

            # Mock user_session.get_output_dir to return our temp directory
            with patch.object(self.user_session, "get_output_dir", return_value=Path(temp_dir)):
                result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

                # Should discover the file and update browser state
                assert result["interactive"] is False
                assert result["variant"] == "secondary"
                assert result["value"] == "音声生成済み"

                # Browser state should be updated
                assert self.browser_state["audio_generation_state"]["final_audio_path"] == str(audio_file)
                assert self.browser_state["audio_generation_state"]["status"] == "completed"
                assert self.browser_state["audio_generation_state"]["is_generating"] is False
                assert self.browser_state["audio_generation_state"]["progress"] == 1.0

    def test_no_discovery_when_script_changed_flag_is_set(self):
        """Test that no disk discovery happens when script_changed flag is set."""
        # Set up browser state with matching script but script_changed flag
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["final_audio_path"] = None
        self.browser_state["audio_generation_state"]["script_changed"] = True

        # Create a temporary directory and audio file
        with tempfile.TemporaryDirectory() as temp_dir:
            audio_file = Path(temp_dir) / "audio_final.wav"
            audio_file.write_text("fake audio content")

            # Mock user_session.get_output_dir to return our temp directory
            with patch.object(self.user_session, "get_output_dir", return_value=Path(temp_dir)):
                result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

                # Should not discover the file and return resume button
                assert result["interactive"] is True
                assert result["variant"] == "primary"
                assert result["value"] == "音声生成を再開"

                # Browser state should not be updated
                assert self.browser_state["audio_generation_state"]["final_audio_path"] is None

    def test_fallback_to_legacy_session_methods_when_no_browser_state(self):
        """Test fallback to legacy UserSession methods when browser_state is None."""
        # Mock UserSession methods
        mock_audio_state = {"current_script": "Test script", "status": "completed"}

        with (
            patch.object(self.user_session, "get_audio_generation_status", return_value=mock_audio_state),
            patch.object(self.user_session, "has_generated_audio", return_value=True),
            patch.object(self.user_session.audio_generator, "final_audio_path", "/path/to/final.wav"),
            patch("os.path.exists", return_value=True),
        ):
            result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=None)

            assert result["interactive"] is False
            assert result["variant"] == "secondary"
            assert result["value"] == "音声生成済み"

    def test_fallback_to_legacy_resume_when_no_final_audio_file(self):
        """Test fallback to legacy resume when no final audio file exists."""
        # Mock UserSession methods
        mock_audio_state = {"current_script": "Test script", "status": "generating"}

        with (
            patch.object(self.user_session, "get_audio_generation_status", return_value=mock_audio_state),
            patch.object(self.user_session, "has_generated_audio", return_value=True),
            patch.object(self.user_session.audio_generator, "final_audio_path", "/path/to/final.wav"),
            patch("os.path.exists", return_value=False),
        ):
            result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=None)

            assert result["interactive"] is True
            assert result["variant"] == "primary"
            assert result["value"] == "音声生成を再開"

    def test_empty_current_script_in_browser_state_returns_generate_button(self):
        """Test that empty current_script in browser state returns generate button."""
        # Set up browser state with empty current_script
        self.browser_state["audio_generation_state"]["current_script"] = ""

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="New script content", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声を生成"

    def test_script_mismatch_returns_generate_button(self):
        """Test that script mismatch returns generate button."""
        # Set up browser state with different script
        self.browser_state["audio_generation_state"]["current_script"] = "Old script"
        self.browser_state["audio_generation_state"]["streaming_parts"] = ["part1.wav"]

        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="New script content", user_session=self.user_session, browser_state=self.browser_state)

        assert result["interactive"] is True
        assert result["variant"] == "primary"
        assert result["value"] == "音声を生成"

    def test_gradio_update_structure(self):
        """Test that result has correct Gradio update structure."""
        result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

        # Check that result has the correct structure for gr.update
        assert "value" in result
        assert "interactive" in result
        assert "variant" in result
        assert isinstance(result["value"], str)
        assert isinstance(result["interactive"], bool)
        assert result["variant"] in ["primary", "secondary"]

    def test_multiple_audio_files_on_disk_uses_first_match(self):
        """Test that when multiple audio files exist on disk, it uses the first match."""
        # Set up browser state with matching script but no final audio
        self.browser_state["audio_generation_state"]["current_script"] = "Test script"
        self.browser_state["audio_generation_state"]["final_audio_path"] = None

        # Create a temporary directory with multiple audio files
        with tempfile.TemporaryDirectory() as temp_dir:
            audio_file1 = Path(temp_dir) / "audio_part1.wav"
            audio_file2 = Path(temp_dir) / "audio_final.wav"
            audio_file1.write_text("fake audio content 1")
            audio_file2.write_text("fake audio content 2")

            # Mock user_session.get_output_dir to return our temp directory
            with patch.object(self.user_session, "get_output_dir", return_value=Path(temp_dir)):
                result = self.app.update_audio_button_state_with_resume_check(checked=True, podcast_text="Test script", user_session=self.user_session, browser_state=self.browser_state)

                # Should discover a file and update browser state
                assert result["interactive"] is False
                assert result["variant"] == "secondary"
                assert result["value"] == "音声生成済み"

                # Browser state should be updated with one of the files
                final_audio_path = self.browser_state["audio_generation_state"]["final_audio_path"]
                assert final_audio_path in [str(audio_file1), str(audio_file2)]
                assert self.browser_state["audio_generation_state"]["status"] == "completed"


class TestCreateProgressHTML:
    """Test _create_progress_html method."""

    def setup_method(self):
        """Set up test fixtures."""
        self.app = PaperPodcastApp()

    def test_basic_progress_display(self):
        """Test basic progress display without completion."""
        result = self.app._create_progress_html(current_part=3, total_parts=10, status_message="音声生成中...", is_completed=False)

        assert "音声生成中..." in result
        assert "パート 3/10" in result
        assert "30.0%" in result
        assert "🎵" in result
        assert "✅" not in result

    def test_completed_progress_display(self):
        """Test completed progress display."""
        result = self.app._create_progress_html(current_part=10, total_parts=10, status_message="生成完了", is_completed=True)

        assert "生成完了" in result
        assert "パート 10/10" in result
        assert "100%" in result
        assert "✅" in result
        assert "🎵" not in result

    def test_progress_calculation_with_zero_total(self):
        """Test progress calculation when total_parts is 0."""
        result = self.app._create_progress_html(current_part=0, total_parts=0, status_message="準備中...", is_completed=False)

        assert "準備中..." in result
        assert "パート 0/0" in result
        assert "0%" in result

    def test_progress_calculation_caps_at_95_percent(self):
        """Test that progress calculation caps at 95% when not completed."""
        result = self.app._create_progress_html(current_part=100, total_parts=100, status_message="最終処理中...", is_completed=False)

        assert "最終処理中..." in result
        assert "パート 100/100" in result
        assert "95%" in result

    def test_time_calculation_with_start_time(self):
        """Test time calculation with start_time provided."""
        import time

        start_time = time.time() - 65  # 1 minute 5 seconds ago

        result = self.app._create_progress_html(current_part=5, total_parts=10, status_message="音声生成中...", is_completed=False, start_time=start_time)

        assert "経過: 01:05" in result
        assert "推定残り:" in result

    def test_time_calculation_when_completed(self):
        """Test time calculation when generation is completed."""
        import time

        start_time = time.time() - 120  # 2 minutes ago

        result = self.app._create_progress_html(current_part=10, total_parts=10, status_message="生成完了", is_completed=True, start_time=start_time)

        assert "完了時間: 02:00" in result
        assert "推定残り:" not in result

    def test_time_calculation_at_start(self):
        """Test time calculation when current_part is 0."""
        import time

        start_time = time.time() - 30  # 30 seconds ago

        result = self.app._create_progress_html(current_part=0, total_parts=10, status_message="開始準備中...", is_completed=False, start_time=start_time)

        assert "経過: 00:30" in result
        assert "推定残り:" not in result

    def test_resume_from_part_display(self):
        """Test display of resume from part information."""
        result = self.app._create_progress_html(current_part=7, total_parts=10, status_message="再開中...", is_completed=False)

        assert "再開中..." in result
        # Resume functionality is handled elsewhere in the app, not in _create_progress_html

    def test_resume_from_part_zero_not_displayed(self):
        """Test that resume from part 0 is not displayed."""
        result = self.app._create_progress_html(current_part=3, total_parts=10, status_message="生成中...", is_completed=False)

        assert "生成中..." in result
        # Resume functionality is handled elsewhere in the app, not in _create_progress_html

    def test_resume_from_part_none_not_displayed(self):
        """Test that resume from part None is not displayed."""
        result = self.app._create_progress_html(current_part=3, total_parts=10, status_message="生成中...", is_completed=False)

        assert "生成中..." in result
        # Resume functionality is handled elsewhere in the app, not in _create_progress_html

    def test_html_structure_contains_required_elements(self):
        """Test that HTML structure contains all required elements."""
        result = self.app._create_progress_html(current_part=3, total_parts=10, status_message="音声生成中...", is_completed=False)

        # Check for HTML structure
        assert "<div" in result
        assert "style=" in result
        assert "background-color:" in result
        assert "border-radius:" in result
        assert "width:" in result
        assert "height:" in result

    def test_estimated_remaining_time_calculation(self):
        """Test estimated remaining time calculation accuracy."""
        import time

        start_time = time.time() - 60  # 1 minute ago

        result = self.app._create_progress_html(current_part=2, total_parts=10, status_message="音声生成中...", is_completed=False, start_time=start_time)

        # With 2 parts in 60 seconds, that's 30 seconds per part
        # 8 remaining parts would be 240 seconds = 4 minutes
        assert "推定残り: 04:00" in result

    def test_edge_case_single_part_generation(self):
        """Test edge case with single part generation."""
        result = self.app._create_progress_html(current_part=1, total_parts=1, status_message="音声生成中...", is_completed=False)

        assert "音声生成中..." in result
        assert "パート 1/1" in result
        assert "95%" in result  # Should cap at 95% when not completed

    def test_edge_case_single_part_completed(self):
        """Test edge case with single part completed."""
        result = self.app._create_progress_html(current_part=1, total_parts=1, status_message="生成完了", is_completed=True)

        assert "生成完了" in result
        assert "パート 1/1" in result
        assert "100%" in result  # Should be 100% when completed

    def test_long_elapsed_time_formatting(self):
        """Test formatting of long elapsed times."""
        import time

        start_time = time.time() - 3665  # 61 minutes 5 seconds ago

        result = self.app._create_progress_html(current_part=5, total_parts=10, status_message="音声生成中...", is_completed=False, start_time=start_time)

        assert "経過: 61:05" in result