from pathlib import Path from unittest.mock import patch from urllib.error import HTTPError import pytest from nodes import web_novel_loader as web URL = "https://novelfull.net/example-book.html" def index_page(numbers, pages=1): links = "".join( f'
  • Chapter {n}: Title {n}
  • ' for n in numbers ) return ( 'Latest chapter' f'
    ' ) def chapter_page(n): return ( f'

    Chapter {n}: Title {n}

    ' f'

    Chapter {n}: Different title

    Translator: Someone

    ' '

    Editor: Someone

    ' f'

    Body {n} begins.

    freёReadNovelFull.com

    ' '

    Body continues with emphasis.

    ' '
    Comments
    ' ) def fake_site(): return { URL: index_page([1, 2], pages=2), URL + "?page=2": index_page([3], pages=2), **{f"https://novelfull.net/example-book/chapter-{n}.html": chapter_page(n) for n in range(1, 4)}, } @pytest.fixture def cached_site(tmp_path, monkeypatch): monkeypatch.setattr(web, "_CACHE_DIR", tmp_path) monkeypatch.setattr(web.time, "sleep", lambda _: None) with patch.object(web, "_fetch_html", side_effect=fake_site().__getitem__) as fetch: yield fetch def test_paginated_index_selects_only_requested_chapter(cached_site): text, title, chapter_list = web.OmniVoiceWebNovelLoader().load_novel(URL, 3, 3) assert text == "Body 3 begins.\n\nBody continues with emphasis." assert title == "Chapter 3: Title 3" assert chapter_list.splitlines() == [ "1. Chapter 1: Title 1", "2. Chapter 2: Title 2", "3. Chapter 3: Title 3", ] assert [call.args[0] for call in cached_site.call_args_list] == [ URL, URL + "?page=2", "https://novelfull.net/example-book/chapter-3.html", ] def test_cached_chapter_can_be_loaded_offline(cached_site): loader = web.OmniVoiceWebNovelLoader() expected = loader.load_novel(URL, 2, 2) cached_site.reset_mock() cached_site.side_effect = RuntimeError("Offline") assert web.OmniVoiceWebNovelLoader().load_novel(URL, 2, 2) == expected cached_site.assert_not_called() def test_counter_fetches_next_chapter_without_reloading_index(cached_site): loader = web.OmniVoiceWebNovelLoader() loader.load_novel(URL, 1, 1) cached_site.reset_mock() text, title, _ = loader.load_novel(URL, 2, 2) assert "Body 2 begins." in text assert title == "Chapter 2: Title 2" cached_site.assert_called_once_with("https://novelfull.net/example-book/chapter-2.html") def test_existing_title_join_speaks_title_once(cached_site): body, title, _ = web.OmniVoiceWebNovelLoader().load_novel(URL, 1, 1) spoken = title + " \n\n" + body.replace("freёReadNovelFull.com\n", "").replace("\\", "") assert spoken.count("Chapter 1") == 1 assert "Translator:" not in spoken assert "Editor:" not in spoken assert "NovelFull.com" not in spoken assert "Next Chapter" not in spoken assert "Comments" not in spoken def test_range_and_end_below_start_match_epub_convention(cached_site): loader = web.OmniVoiceWebNovelLoader() text, _, _ = loader.load_novel(URL, 2, 999) assert "Body 1" not in text assert "Body 2" in text and "Body 3" in text assert "\n\n---\n\n" in text text, _, _ = loader.load_novel(URL, 2, 1) assert "Body 2" in text and "Body 3" not in text def test_counter_beyond_last_chapter_stops_instead_of_repeating(cached_site): with pytest.raises(ValueError, match="outside this novel's range"): web.OmniVoiceWebNovelLoader().load_novel(URL, 4, 4) assert cached_site.call_count == 2 # index pages only def test_refresh_redownloads_index_and_selected_chapter(cached_site): loader = web.OmniVoiceWebNovelLoader() loader.load_novel(URL, 1, 1) cached_site.reset_mock() loader.load_novel(URL, 1, 1, refresh_cache=True) assert cached_site.call_count == 3 def test_old_pagination_layout_and_duplicate_links(): html = '''
    Chapter 1 Chapter 1 Other book Outside Last
    ''' chapters, pages = web._parse_index(html, URL) assert pages == 3 assert chapters == [{"title": "Chapter 1", "url": "https://novelfull.net/example-book/chapter-1.html"}] def test_repeated_index_page_is_not_cached(tmp_path, monkeypatch): monkeypatch.setattr(web.time, "sleep", lambda _: None) with patch.object(web, "_fetch_html", return_value=index_page([1], pages=2)): with pytest.raises(ValueError, match="index is incomplete"): web._load_index(URL, tmp_path, False) assert not (tmp_path / "index.json").exists() @pytest.mark.parametrize("url", [ "https://example.org/book.html", "file:///tmp/book.html", "https://novelfull.net@example.org/book.html", "http://127.0.0.1/book.html", "https://novelfull.net:9999/book.html", "https://novelfull.net/example-book/chapter-1.html", ]) def test_invalid_urls_are_rejected_before_network_access(url): with patch.object(web, "_fetch_html") as fetch: with pytest.raises(ValueError): web.OmniVoiceWebNovelLoader().load_novel(url, 1, 1) fetch.assert_not_called() def test_pasted_page_url_starts_index_at_first_page(cached_site): text, _, _ = web.OmniVoiceWebNovelLoader().load_novel(URL + "?page=2#list", 1, 1) assert "Body 1 begins." in text assert cached_site.call_args_list[0].args == (URL,) def test_missing_or_empty_body_does_not_narrate_page_navigation(): chapter = {"title": "Chapter 1", "url": URL} for html in ["

    Blocked

    ", '

    Chapter 1

    Translator: Someone

    ']: with pytest.raises(ValueError): web._parse_chapter(html, chapter) def test_inline_breaks_ads_and_story_paragraphs_are_preserved(): html = '''

    Chapter 1

    First story paragraph.
    Second paragraph.

    Ad

    Translator: a character's job, inside the story.

    Before freeReadNovelFull.com after.

    ''' result = web._parse_chapter(html, {"title": "Chapter 1", "url": URL}) assert result["text"] == ( "First story paragraph.\n\nSecond paragraph.\n\n" "Translator: a character's job, inside the story.\n\nBefore after." ) def test_http_block_is_actionable_and_not_retried(): with patch.object(web, "urlopen", side_effect=HTTPError(URL, 403, "Forbidden", {}, None)) as request: with pytest.raises(RuntimeError, match="HTTP 403"): web._fetch_html(URL) assert request.call_count == 1 def test_transient_http_error_is_retried(monkeypatch): monkeypatch.setattr(web.time, "sleep", lambda _: None) with patch.object(web, "urlopen") as request: response = request.return_value.__enter__.return_value response.geturl.return_value = URL response.read.return_value = b"Success" request.side_effect = [HTTPError(URL, 503, "Unavailable", {}, None), request.return_value] assert web._fetch_html(URL) == "Success" assert request.call_count == 2