MomsVoiceAI / test_modules /test_integration.py
minhahwang's picture
test: add comprehensive integration test suite (74 tests)
122c643
Raw
History Blame Contribute Delete
13.1 kB
"""
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", "&lt;script&gt;" 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", "&lt;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)