Spaces:
Runtime error
Runtime error
| """ | |
| Comprehensive integration tests for the ReadBookMom app. | |
| Tests cover: | |
| - Module imports and structure | |
| - Story loading and text processing | |
| - HTML rendering functions (dashboard, library, voices, story text) | |
| - Gradio component wiring (inputs/outputs match handler signatures) | |
| - State machine transitions (book select β play β pause β ask β resume) | |
| - XSS escaping in all HTML renderers | |
| - Voice profile integration in player | |
| - TTS module chunk splitting | |
| - Q&A answer rendering safety | |
| """ | |
| import os | |
| import sys | |
| import html as html_module | |
| import inspect | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) | |
| PASS = 0 | |
| FAIL = 0 | |
| def check(label, condition, detail=""): | |
| global PASS, FAIL | |
| if condition: | |
| PASS += 1 | |
| print(f" [OK] {label}") | |
| else: | |
| FAIL += 1 | |
| print(f" [FAIL] {label} -- {detail}") | |
| # ββ Module imports ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_module_imports(): | |
| print("\n=== Module imports ===") | |
| try: | |
| from tts import split_into_chunks, generate_audio_stream | |
| check("tts module imports", True) | |
| except Exception as e: | |
| check("tts module imports", False, str(e)) | |
| try: | |
| from voice_clone import ( | |
| create_voice_profile, synthesize_cloned, synthesize_cloned_preview, | |
| synthesize_custom_voice, has_profile, get_model_mode, | |
| _trim_reference_audio, _select_dtype, _select_attn_impl, | |
| GENERATION_PARAMS, BASE_MODEL_ID, CUSTOM_VOICE_MODEL_ID, | |
| ) | |
| check("voice_clone module imports", True) | |
| except Exception as e: | |
| check("voice_clone module imports", False, str(e)) | |
| try: | |
| from inference import transcribe_audio, answer_story_question | |
| check("inference module imports", True) | |
| except Exception as e: | |
| check("inference module imports", False, str(e)) | |
| # ββ Story loading βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_story_loading(): | |
| print("\n=== Story loading ===") | |
| from app import load_books_from_stories, load_paragraphs | |
| books = load_books_from_stories() | |
| check("Stories found", len(books) > 0, f"found {len(books)}") | |
| for b in books: | |
| required = ["id", "title", "story_path", "cover_url", "voice_name", "percentage"] | |
| missing = [k for k in required if k not in b] | |
| check(f"Book '{b['title']}' has all keys", len(missing) == 0, f"missing: {missing}") | |
| # Test paragraph loading | |
| if books: | |
| paras = load_paragraphs(books[0]["story_path"]) | |
| check(f"Paragraphs loaded from '{books[0]['title']}'", len(paras) > 0, f"got {len(paras)}") | |
| # Test non-existent file | |
| paras = load_paragraphs("nonexistent_file.txt") | |
| check("Non-existent file returns empty list", paras == []) | |
| # ββ TTS chunk splitting ββββββββββββββββββββββββββββββββββββββββ | |
| def test_chunk_splitting(): | |
| print("\n=== TTS chunk splitting ===") | |
| from tts import split_into_chunks | |
| chunks = split_into_chunks("Hello world. How are you? I am fine!") | |
| check("Splits on sentence boundaries", len(chunks) == 3, f"got {len(chunks)}: {chunks}") | |
| chunks = split_into_chunks("Single sentence without ending period") | |
| check("Single sentence stays intact", len(chunks) == 1, f"got {len(chunks)}") | |
| chunks = split_into_chunks("") | |
| check("Empty text returns empty list", len(chunks) == 0) | |
| chunks = split_into_chunks(" ") | |
| check("Whitespace-only returns empty list", len(chunks) == 0) | |
| long_text = "First sentence. Second sentence! Third? Fourth; Fifth." | |
| chunks = split_into_chunks(long_text) | |
| check("Splits on .!?; delimiters", len(chunks) == 5, f"got {len(chunks)}: {chunks}") | |
| # ββ HTML rendering ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_html_rendering(): | |
| print("\n=== HTML rendering ===") | |
| from app import ( | |
| generate_dashboard_html, generate_library_html, | |
| render_cloned_voices_html, render_story_text, mock_books, mock_voices, | |
| ) | |
| # Dashboard | |
| dash_html = generate_dashboard_html(mock_books) | |
| check("Dashboard renders without error", isinstance(dash_html, str) and len(dash_html) > 100) | |
| check("Dashboard contains book titles", any(b["title"] in dash_html for b in mock_books)) | |
| # Library | |
| lib_html = generate_library_html(mock_books) | |
| check("Library renders without error", isinstance(lib_html, str) and len(lib_html) > 100) | |
| check("Library contains Tap to Play", "Tap to Play" in lib_html) | |
| # Library with search filter | |
| if mock_books: | |
| first_title = mock_books[0]["title"] | |
| filtered = generate_library_html(mock_books, query=first_title) | |
| check(f"Library filters to '{first_title}'", first_title in filtered) | |
| # Library empty result | |
| empty = generate_library_html(mock_books, query="xyznonexistent999") | |
| check("Library shows no-match message", "No audiobooks match" in empty) | |
| # Cloned voices | |
| voices_html = render_cloned_voices_html(mock_voices) | |
| check("Voices grid renders", isinstance(voices_html, str) and len(voices_html) > 50) | |
| # Story text | |
| paras = ["Paragraph one.", "Paragraph two.", "Paragraph three."] | |
| story_html = render_story_text(paras, 1) | |
| check("Story text highlights current paragraph", "border-left: 3px solid #f5841f" in story_html) | |
| check("Story text renders all paragraphs", "Paragraph one" in story_html and "Paragraph three" in story_html) | |
| # Empty paragraphs | |
| check("Empty paragraphs returns empty string", render_story_text([], 0) == "") | |
| # ββ XSS escaping βββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_xss_escaping(): | |
| print("\n=== XSS escaping ===") | |
| from app import render_story_text, render_cloned_voices_html | |
| # Story text with HTML injection | |
| xss_paras = ['<script>alert("xss")</script>', 'Normal text'] | |
| result = render_story_text(xss_paras, 0) | |
| check("Story text escapes <script>", "<script>" not in result) | |
| check("Story text contains escaped version", "<script>" in result) | |
| # Voices with HTML injection | |
| xss_voices = [{ | |
| "id": "v1", "name": '<img onerror="alert(1)" src=x>', | |
| "avatar_url": "π©", "description": "test", "gender": "Female", | |
| "created_date": "2026-01-01", "status": "ready" | |
| }] | |
| result = render_cloned_voices_html(xss_voices) | |
| check("Voice name escapes HTML", "<img onerror" not in result) | |
| check("Voice name contains escaped version", "<img" in result) | |
| # ββ Gradio component wiring ββββββββββββββββββββββββββββββββββββ | |
| def test_gradio_wiring(): | |
| print("\n=== Gradio component wiring ===") | |
| from app import demo | |
| # Verify the demo has the expected blocks | |
| blocks = demo.blocks | |
| check("Demo has blocks", len(blocks) > 0, f"found {len(blocks)}") | |
| # Check that no component references a deleted 'loading_spinner' | |
| source = open("app.py", encoding="utf-8").read() | |
| check("No loading_spinner references", "loading_spinner" not in source) | |
| check("No empty_state_sim references", "empty_state_sim" not in source) | |
| check("No offline_toggle references", "offline_toggle" not in source) | |
| check("No system_error_modal references", "system_error_modal" not in source) | |
| check("No narrative_subtitles references", "narrative_subtitles" not in source) | |
| # ββ Handler signatures vs outputs ββββββββββββββββββββββββββββββ | |
| def test_handler_signatures(): | |
| print("\n=== Handler signatures ===") | |
| source = open("app.py", encoding="utf-8").read() | |
| # Verify key handler functions exist | |
| handlers = [ | |
| "def filter_books_shelf", | |
| "def handle_book_select", | |
| "def animate_cloning_pipeline", | |
| "def save_new_cloned_voice", | |
| "def stream_tts", | |
| "def enter_paused_state", | |
| "def update_story_highlight", | |
| "def enter_asking_state", | |
| "def handle_question_submit", | |
| "def enter_resume_state", | |
| ] | |
| for h in handlers: | |
| check(f"Handler {h.split('def ')[1]} exists", h in source) | |
| # Verify removed handlers are gone | |
| check("check_story_finished removed (inline in stream_tts)", "def check_story_finished" not in source) | |
| check("toggle_outage_sandbox removed", "def toggle_outage_sandbox" not in source) | |
| # ββ Voice profile integration ββββββββββββββββββββββββββββββββββ | |
| def test_voice_profile_integration(): | |
| print("\n=== Voice profile integration ===") | |
| from voice_clone import has_profile | |
| check("has_profile(None) is False", has_profile(None) is False) | |
| check("has_profile('') is False", has_profile("") is False) | |
| check("has_profile('nonexistent') is False", has_profile("nonexistent") is False) | |
| # Verify handle_book_select accepts profile_id | |
| source = open("app.py", encoding="utf-8").read() | |
| check("handle_book_select takes profile_id", | |
| "def handle_book_select(title_chosen, current_inventory, profile_id)" in source) | |
| check("book_selector inputs include voice_profile_state", | |
| "inputs=[book_selector, books_state, voice_profile_state]" in source) | |
| # ββ State machine consistency ββββββββββββββββββββββββββββββββββ | |
| def test_state_machine(): | |
| print("\n=== State machine consistency ===") | |
| source = open("app.py", encoding="utf-8").read() | |
| # Play β shows pause_btn, hides play_btn | |
| check("stream_tts hides play_btn during playback", | |
| "gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True)" in source) | |
| # Pause β shows play_btn, hides pause_btn | |
| check("enter_paused_state shows play_btn", | |
| "gr.Button(visible=True),\n gr.Button(visible=False)," in source or | |
| ("enter_paused_state" in source and "gr.Button(visible=True)" in source)) | |
| # Resume β shows play_btn (ready state), hides resume_btn | |
| check("enter_resume_state shows ready state", | |
| "READY" in source and "Tap Play to continue" in source) | |
| # Ask β shows resume_btn, hides play/pause/ask | |
| check("enter_asking_state shows resume_btn", | |
| "PAUSED" in source and "ASKING" in source) | |
| # stream_tts updates timeline_slider | |
| check("stream_tts outputs include timeline_slider", | |
| "timeline_slider, story_text_display, story_finished_panel" in source) | |
| # ββ App launch readiness βββββββββββββββββββββββββββββββββββββββ | |
| def test_app_launch(): | |
| print("\n=== App launch readiness ===") | |
| from app import demo | |
| check("Demo object exists", demo is not None) | |
| check("Demo is a gr.Blocks instance", "Blocks" in type(demo).__name__) | |
| # Check allowed_paths in launch call | |
| source = open("app.py", encoding="utf-8").read() | |
| check("Launch has allowed_paths for assets", 'allowed_paths' in source) | |
| check("Launch uses .queue()", '.queue()' in source) | |
| # ββ File structure ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_file_structure(): | |
| print("\n=== File structure ===") | |
| required_files = [ | |
| "app.py", "tts.py", "voice_clone.py", "inference.py", | |
| "requirements.txt", "static/style.css", | |
| ] | |
| for f in required_files: | |
| path = os.path.join(os.path.dirname(os.path.dirname(__file__)), f) | |
| check(f"File exists: {f}", os.path.exists(path)) | |
| # Check stories directory has content | |
| stories_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "stories") | |
| if os.path.isdir(stories_dir): | |
| txt_files = [f for f in os.listdir(stories_dir) if f.endswith(".txt")] | |
| check("Stories directory has .txt files", len(txt_files) > 0, f"found {len(txt_files)}") | |
| else: | |
| check("Stories directory exists", False, "stories/ not found") | |
| if __name__ == "__main__": | |
| print("=" * 60) | |
| print("Comprehensive Integration Tests β ReadBookMom") | |
| print("=" * 60) | |
| test_module_imports() | |
| test_story_loading() | |
| test_chunk_splitting() | |
| test_html_rendering() | |
| test_xss_escaping() | |
| test_gradio_wiring() | |
| test_handler_signatures() | |
| test_voice_profile_integration() | |
| test_state_machine() | |
| test_app_launch() | |
| test_file_structure() | |
| print("\n" + "=" * 60) | |
| print(f"Results: {PASS} passed, {FAIL} failed") | |
| print("=" * 60) | |
| sys.exit(1 if FAIL > 0 else 0) | |