From bb7fef2cd3ddf863d1bf63728957dfeb2a5d67c1 Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 12 Sep 2026 18:28:08 +0900 Subject: [PATCH 1/3] test: trim and parametrize low-value tests (test-trim-audit) --- tests/_firestore_fakes.py | 156 ++ tests/acceptance_traceability.py | 3 +- tests/test_acceptance_suite.py | 21 - tests/test_acceptance_traceability.py | 77 - tests/test_availability.py | 225 +- tests/test_check.py | 581 ++-- tests/test_discord_add.py | 248 +- tests/test_discord_command_registration.py | 53 +- ...est_discord_command_registration_search.py | 19 - tests/test_discord_interactions.py | 13 - tests/test_discord_interactions_search.py | 46 - tests/test_discord_interactions_where.py | 97 - tests/test_discord_latest.py | 563 ++-- tests/test_discord_outbound.py | 267 +- tests/test_discord_remove.py | 2 +- tests/test_discord_search.py | 431 +-- tests/test_discord_supertwins_interactions.py | 108 - tests/test_discord_title_search.py | 5 +- tests/test_discord_where.py | 444 ++-- tests/test_firestore_storage.py | 231 +- tests/test_github_issue_reporting.py | 162 +- tests/test_github_workflows.py | 313 ++- tests/test_migrate_storage.py | 2 +- tests/test_print_cloud_scheduler_job.py | 63 +- tests/test_repair_mojibake.py | 33 +- tests/test_run_service.py | 93 +- tests/test_runner.py | 162 +- tests/test_source_drift.py | 117 +- tests/test_source_search.py | 720 +++-- tests/test_sources.py | 2366 ++++++++--------- tests/test_storage.py | 130 +- tests/test_watchlist.py | 433 --- tests/test_web_admin_settings.py | 1 - 33 files changed, 3545 insertions(+), 4640 deletions(-) create mode 100644 tests/_firestore_fakes.py delete mode 100644 tests/test_acceptance_suite.py delete mode 100644 tests/test_acceptance_traceability.py delete mode 100644 tests/test_web_admin_settings.py diff --git a/tests/_firestore_fakes.py b/tests/_firestore_fakes.py new file mode 100644 index 0000000..5869d5c --- /dev/null +++ b/tests/_firestore_fakes.py @@ -0,0 +1,156 @@ +"""Shared Firestore fakes for tests (test-only helper). + +Moved verbatim from tests/test_firestore_storage.py (Todo 10 decoupling). +Import from here instead of tests.test_firestore_storage to avoid +test-to-test imports. stdlib-only; no imports from tests/* (no cycles). +""" + +import json + + +def make_watchlist(): + return { + "version": 2, + "works": [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://comic-walker.com/detail/work-1", + "enabled": True, + "notification_policy": { + "mode": "all", + "allowed_update_types": None, + }, + } + ], + } + + +def make_state(): + return { + "version": 2, + "works": { + "work-1": { + "latest": { + "series_title": "作品A", + "episode_title": "第2話", + "latest_key": "episode-2", + "url": "https://example.com/episodes/2", + "update_type": "main_story", + "default_notify": True, + }, + "history": [ + { + "event_id": "event-1", + "detected_at": "2023-11-14T22:13:20Z", + "latest": { + "series_title": "作品A", + "episode_title": "第2話", + "latest_key": "episode-2", + }, + } + ], + "unread": {"event_ids": ["event-1"]}, + } + }, + "last_run_at": 1_700_000_000, + "notification_outbox": [ + { + "event": { + "schema_version": 1, + "event_id": "event-1", + "work_id": "work-1", + "latest_key": "episode-2", + "series_title": "作品A", + "update_type": "main_story", + "detected_at": "2023-11-14T22:13:20Z", + "from": {"latest_key": "episode-1"}, + "to": {"latest_key": "episode-2"}, + }, + "pending_backends": ["stdout", "webhook"], + "attempt_count": 1, + "last_attempted_at": "2023-11-14T22:15:00Z", + "last_error": "webhook timeout", + } + ], + "discord_delivery": { + "daily_notification": { + "delivered_latest_keys": { + "work-1": { + "latest_key": "episode-2", + "delivered_at": None, + }, + }, + "pending_messages": [ + { + "channel_id": "main-channel", + "content": "pending daily message", + "message_keys": [ + { + "work_id": "work-1", + "latest_key": "episode-2", + } + ], + "created_at": "2023-11-14T22:13:20Z", + "attempt_count": 0, + "last_attempted_at": None, + "last_error": None, + } + ], + } + }, + } + + +class FakeSnapshot: + def __init__(self, doc_id, payload): + self.id = doc_id + self._payload = None if payload is None else json.loads(json.dumps(payload, ensure_ascii=False)) + + @property + def exists(self): + return self._payload is not None + + def to_dict(self): + if self._payload is None: + return None + return json.loads(json.dumps(self._payload, ensure_ascii=False)) + + +class FakeDocument: + def __init__(self, store, collection_name, doc_id): + self.store = store + self.collection_name = collection_name + self.doc_id = doc_id + + def get(self): + return FakeSnapshot(self.doc_id, self.store.get(self.collection_name, {}).get(self.doc_id)) + + def set(self, payload): + self.store.setdefault(self.collection_name, {})[self.doc_id] = json.loads( + json.dumps(payload, ensure_ascii=False) + ) + + def delete(self): + self.store.setdefault(self.collection_name, {}).pop(self.doc_id, None) + + +class FakeCollection: + def __init__(self, store, collection_name): + self.store = store + self.collection_name = collection_name + + def document(self, doc_id): + return FakeDocument(self.store, self.collection_name, doc_id) + + def stream(self): + docs = self.store.setdefault(self.collection_name, {}) + return [FakeSnapshot(doc_id, payload) for doc_id, payload in sorted(docs.items())] + + +class FakeFirestoreClient: + def __init__(self): + self.store = {} + + def collection(self, collection_name): + return FakeCollection(self.store, collection_name) diff --git a/tests/acceptance_traceability.py b/tests/acceptance_traceability.py index 6fb54dd..8ace6cc 100644 --- a/tests/acceptance_traceability.py +++ b/tests/acceptance_traceability.py @@ -152,8 +152,7 @@ "TC-FETCH-03": { "layer": "orchestration", "tests": [ - "tests.test_runner.RunnerTests.test_run_coordinator_queues_scheduled_while_fetch_is_in_progress", - "tests.test_runner.RunnerTests.test_run_coordinator_queues_startup_while_fetch_is_in_progress", + "tests.test_runner.RunnerTests.test_run_coordinator_queues_second_trigger_while_fetch_is_in_progress", ], }, "TC-FETCH-04": { diff --git a/tests/test_acceptance_suite.py b/tests/test_acceptance_suite.py deleted file mode 100644 index 9ce57b7..0000000 --- a/tests/test_acceptance_suite.py +++ /dev/null @@ -1,21 +0,0 @@ -import importlib -import unittest - -from tests.acceptance_suite import ( - MOCKED_ACCEPTANCE_MODULES, - TRACEABILITY_CASES, - TRACEABILITY_MODULES, -) - - -class AcceptanceSuiteRegistryTests(unittest.TestCase): - def test_traceability_modules_match_mocked_suite(self): - self.assertEqual(set(MOCKED_ACCEPTANCE_MODULES), set(TRACEABILITY_MODULES)) - - def test_traceability_manifest_is_non_empty(self): - self.assertTrue(TRACEABILITY_CASES) - - def test_mocked_acceptance_modules_are_importable(self): - for module_name in MOCKED_ACCEPTANCE_MODULES: - imported = importlib.import_module(module_name) - self.assertIsNotNone(imported) diff --git a/tests/test_acceptance_traceability.py b/tests/test_acceptance_traceability.py deleted file mode 100644 index b914258..0000000 --- a/tests/test_acceptance_traceability.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import importlib -import unittest - -from tests.acceptance_traceability import ACCEPTANCE_TRACEABILITY - - -EXPECTED_MOCKED_CASE_IDS = { - "TC-LATEST-01", - "TC-LATEST-02", - "TC-LATEST-03", - "TC-LATEST-04", - "TC-LATEST-05", - "TC-LATEST-06", - "TC-LATEST-07", - "TC-LATEST-08", - "TC-LATEST-09", - "TC-LATEST-10", - "TC-LATEST-11", - "TC-LATEST-12", - "TC-DAILY-01", - "TC-DAILY-02", - "TC-DAILY-03", - "TC-DAILY-04", - "TC-DAILY-05", - "TC-DAILY-06", - "TC-DAILY-07", - "TC-DAILY-08", - "TC-DAILY-09", - "TC-DAILY-10", - "TC-FETCH-01", - "TC-FETCH-02", - "TC-FETCH-03", - "TC-FETCH-04", - "TC-REPORT-01", - "TC-REPORT-02", - "TC-REPORT-03", - "TC-REPORT-04", - "TC-DELIVERY-01", - "TC-DELIVERY-02", - "TC-DELIVERY-03", - "TC-DELIVERY-04", - "TC-STATE-01", - "TC-STATE-02", - "TC-STATE-03", - "TC-SEC-01", - "TC-SEC-02", - "TC-SEC-03", -} - -VALID_LAYERS = { - "formatter", - "mocked_discord_integration", - "orchestration", - "persistence", - "security", -} - - -class AcceptanceTraceabilityTests(unittest.TestCase): - def test_manifest_covers_mocked_acceptance_cases(self): - self.assertEqual(EXPECTED_MOCKED_CASE_IDS, set(ACCEPTANCE_TRACEABILITY)) - - def test_manifest_uses_known_layers(self): - for case_id, entry in ACCEPTANCE_TRACEABILITY.items(): - self.assertIn(entry["layer"], VALID_LAYERS, case_id) - - def test_manifest_points_to_existing_tests(self): - for case_id, entry in ACCEPTANCE_TRACEABILITY.items(): - self.assertTrue(entry["tests"], case_id) - for dotted_path in entry["tests"]: - module_name, class_name, method_name = dotted_path.rsplit(".", 2) - module = importlib.import_module(module_name) - klass = getattr(module, class_name) - self.assertTrue(issubclass(klass, unittest.TestCase), dotted_path) - self.assertTrue(hasattr(klass, method_name), dotted_path) diff --git a/tests/test_availability.py b/tests/test_availability.py index c46dda4..c540b4f 100644 --- a/tests/test_availability.py +++ b/tests/test_availability.py @@ -18,142 +18,139 @@ def test_supported_availability_sources_are_manga_sources_only(self): self.assertEqual(("comic-walker", "nicovideo-manga"), supported_availability_sources()) self.assertNotIn("kakuyomu", supported_availability_sources()) - def test_comic_walker_resolves_exact_episode_url(self): - seed_url = "https://comic-walker.com/detail/KC_004800_S" - html = """ - - 第1話 - 第2話① - - """ - - result = resolve_episode_availability( - "comic-walker", - seed_url, - "1話", - http_client=StaticHttpClient({seed_url: html}), - ) - - self.assertEqual( + def test_resolves_exact_episode_url(self): + cases = ( { "source": "comic-walker", - "status": "free_now", - "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E", + "seed_url": "https://comic-walker.com/detail/KC_004800_S", + "query": "1話", + "html": """ + + 第1話 + 第2話① + + """, + "expected": { + "source": "comic-walker", + "status": "free_now", + "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E", + }, + }, + { + "source": "nicovideo-manga", + "seed_url": "https://manga.nicovideo.jp/comic/62782", + "query": "第1話", + "html": """ + + ニセモノの錬金術師 第1話 / 杉浦次郎 + ニセモノの錬金術師 第2話 / 杉浦次郎 + + """, + "expected": { + "source": "nicovideo-manga", + "status": "free_now", + "url": "https://manga.nicovideo.jp/watch/mg1000001", + }, }, - result, - ) - - def test_comic_walker_does_not_match_nearby_episode_number(self): - seed_url = "https://comic-walker.com/detail/KC_004800_S" - html = """ - - 第10話 - 第11話 - - """ - - result = resolve_episode_availability( - "comic-walker", - seed_url, - "第1話", - http_client=StaticHttpClient({seed_url: html}), ) - self.assertEqual({"source": "comic-walker", "status": "not_found", "url": None}, result) + for entry in cases: + with self.subTest(source=entry["source"]): + result = resolve_episode_availability( + entry["source"], + entry["seed_url"], + entry["query"], + http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}), + ) - def test_comic_walker_binds_episode_label_to_same_anchor(self): - seed_url = "https://comic-walker.com/detail/KC_004800_S" - html = """ - - 第2話 - 第1話 - - """ - - result = resolve_episode_availability( - "comic-walker", - seed_url, - "第1話", - http_client=StaticHttpClient({seed_url: html}), - ) + self.assertEqual(entry["expected"], result) - self.assertEqual( + def test_does_not_match_nearby_episode_number(self): + cases = ( { "source": "comic-walker", - "status": "free_now", - "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E", + "seed_url": "https://comic-walker.com/detail/KC_004800_S", + "query": "第1話", + "html": """ + + 第10話 + 第11話 + + """, + "expected": {"source": "comic-walker", "status": "not_found", "url": None}, }, - result, - ) - - def test_nicovideo_manga_resolves_exact_episode_url(self): - seed_url = "https://manga.nicovideo.jp/comic/62782" - html = """ - - ニセモノの錬金術師 第1話 / 杉浦次郎 - ニセモノの錬金術師 第2話 / 杉浦次郎 - - """ - - result = resolve_episode_availability( - "nicovideo-manga", - seed_url, - "第1話", - http_client=StaticHttpClient({seed_url: html}), - ) - - self.assertEqual( { "source": "nicovideo-manga", - "status": "free_now", - "url": "https://manga.nicovideo.jp/watch/mg1000001", + "seed_url": "https://manga.nicovideo.jp/comic/62782", + "query": "1話", + "html": """ + + ニセモノの錬金術師 第10話 / 杉浦次郎 + ニセモノの錬金術師 第11話 / 杉浦次郎 + + """, + "expected": {"source": "nicovideo-manga", "status": "not_found", "url": None}, }, - result, ) - def test_nicovideo_manga_does_not_match_nearby_episode_number(self): - seed_url = "https://manga.nicovideo.jp/comic/62782" - html = """ - - ニセモノの錬金術師 第10話 / 杉浦次郎 - ニセモノの錬金術師 第11話 / 杉浦次郎 - - """ - - result = resolve_episode_availability( - "nicovideo-manga", - seed_url, - "1話", - http_client=StaticHttpClient({seed_url: html}), - ) + for entry in cases: + with self.subTest(source=entry["source"]): + result = resolve_episode_availability( + entry["source"], + entry["seed_url"], + entry["query"], + http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}), + ) - self.assertEqual({"source": "nicovideo-manga", "status": "not_found", "url": None}, result) + self.assertEqual(entry["expected"], result) - def test_nicovideo_manga_binds_episode_label_to_same_anchor(self): - seed_url = "https://manga.nicovideo.jp/comic/62782" - html = """ - - ニセモノの錬金術師 第2話 / 杉浦次郎 - ニセモノの錬金術師 第1話 / 杉浦次郎 - - """ - - result = resolve_episode_availability( - "nicovideo-manga", - seed_url, - "第1話", - http_client=StaticHttpClient({seed_url: html}), - ) - - self.assertEqual( + def test_binds_episode_label_to_same_anchor(self): + cases = ( + { + "source": "comic-walker", + "seed_url": "https://comic-walker.com/detail/KC_004800_S", + "query": "第1話", + "html": """ + + 第2話 + 第1話 + + """, + "expected": { + "source": "comic-walker", + "status": "free_now", + "url": "https://comic-walker.com/detail/KC_004800_S/episodes/KC_0048000000100012_E", + }, + }, { "source": "nicovideo-manga", - "status": "free_now", - "url": "https://manga.nicovideo.jp/watch/mg1000001", + "seed_url": "https://manga.nicovideo.jp/comic/62782", + "query": "第1話", + "html": """ + + ニセモノの錬金術師 第2話 / 杉浦次郎 + ニセモノの錬金術師 第1話 / 杉浦次郎 + + """, + "expected": { + "source": "nicovideo-manga", + "status": "free_now", + "url": "https://manga.nicovideo.jp/watch/mg1000001", + }, }, - result, ) + for entry in cases: + with self.subTest(source=entry["source"]): + result = resolve_episode_availability( + entry["source"], + entry["seed_url"], + entry["query"], + http_client=StaticHttpClient({entry["seed_url"]: entry["html"]}), + ) + + self.assertEqual(entry["expected"], result) + def test_returns_not_found_without_exact_episode(self): seed_url = "https://manga.nicovideo.jp/comic/62782" html = 'ニセモノの錬金術師 第2話 / 杉浦次郎' diff --git a/tests/test_check.py b/tests/test_check.py index 29b4984..eb1c049 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -215,92 +215,53 @@ def test_check_module_runs_via_python_m(self): self.assertEqual({}, saved_state["works"]) self.assertIsInstance(saved_state["last_run_at"], int) - def test_check_module_reports_load_watchlist_failures_as_json(self): - missing_watchlist_path = Path(tempfile.gettempdir()) / "comic-crawler-missing-watchlist.json" - if missing_watchlist_path.exists(): - missing_watchlist_path.unlink() - - result = self.run_check_module(missing_watchlist_path) - - self.assertEqual(1, result.returncode) - self.assertEqual( - { - "updates": [], - "errors": { - "sources": [], - "run": [ - { + def test_check_module_reports_stage_failures_as_json(self): + for failure_point in ("load_watchlist", "load_state", "save_state", "http_config"): + with self.subTest(failure_point=failure_point): + with tempfile.TemporaryDirectory() as tmpdir: + watchlist_path = Path(tmpdir) / "watchlist.json" + state_path = Path(tmpdir) / "state.json" + write_watchlist(watchlist_path, []) + extra_env = {"MANGA_WATCH_STATE": str(state_path)} + expected = {} + if failure_point == "load_watchlist": + watchlist_path = Path(tempfile.gettempdir()) / "comic-crawler-missing-watchlist.json" + if watchlist_path.exists(): + watchlist_path.unlink() + extra_env = {} + expected = { "stage": "load_watchlist", "kind": "runtime", "errorType": "FileNotFoundError", - "message": f"[Errno 2] No such file or directory: '{missing_watchlist_path}'", + "message": f"[Errno 2] No such file or directory: '{watchlist_path}'", } - ], - }, - }, - json.loads(result.stdout), - ) - self.assertEqual("", result.stderr) - - def test_check_module_reports_load_state_failures_as_json(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - state_path = Path(tmpdir) / "state.json" - write_watchlist(watchlist_path, []) - state_path.write_text("{broken json", encoding="utf-8") - - result = self.run_check_module( - watchlist_path, - extra_env={"MANGA_WATCH_STATE": str(state_path)}, - ) - - self.assertEqual(1, result.returncode) - payload = json.loads(result.stdout) - self.assertEqual([], payload["updates"]) - self.assertEqual([], payload["errors"]["sources"]) - self.assertEqual("load_state", payload["errors"]["run"][0]["stage"]) - self.assertEqual("JSONDecodeError", payload["errors"]["run"][0]["errorType"]) - self.assertEqual("", result.stderr) - - def test_check_module_reports_save_state_failures_as_json(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - result = self.run_check_module( - watchlist_path, - extra_env={"MANGA_WATCH_STATE": "/dev/null/state.json"}, - ) - - self.assertEqual(1, result.returncode) - payload = json.loads(result.stdout) - self.assertEqual([], payload["updates"]) - self.assertEqual([], payload["errors"]["sources"]) - self.assertEqual("save_state", payload["errors"]["run"][0]["stage"]) - self.assertEqual("FileExistsError", payload["errors"]["run"][0]["errorType"]) - self.assertEqual("", result.stderr) - - def test_check_module_reports_invalid_http_config_as_json(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - state_path = Path(tmpdir) / "state.json" - write_watchlist(watchlist_path, []) - - result = self.run_check_module( - watchlist_path, - extra_env={ - "MANGA_WATCH_STATE": str(state_path), - "MANGA_WATCH_HTTP_WORKERS": "0", - }, - ) - - self.assertEqual(1, result.returncode) - payload = json.loads(result.stdout) - self.assertEqual([], payload["updates"]) - self.assertEqual([], payload["errors"]["sources"]) - self.assertEqual("http_config", payload["errors"]["run"][0]["stage"]) - self.assertEqual("ValueError", payload["errors"]["run"][0]["errorType"]) - self.assertEqual("", result.stderr) + elif failure_point == "load_state": + state_path.write_text("{broken json", encoding="utf-8") + expected = {"stage": "load_state", "errorType": "JSONDecodeError"} + elif failure_point == "save_state": + extra_env = {"MANGA_WATCH_STATE": "/dev/null/state.json"} + expected = {"stage": "save_state", "errorType": "FileExistsError"} + else: + extra_env = { + "MANGA_WATCH_STATE": str(state_path), + "MANGA_WATCH_HTTP_WORKERS": "0", + } + expected = {"stage": "http_config", "errorType": "ValueError"} + + result = self.run_check_module(watchlist_path, extra_env=extra_env) + + self.assertEqual(1, result.returncode) + payload = json.loads(result.stdout) + self.assertEqual([], payload["updates"]) + self.assertEqual([], payload["errors"]["sources"]) + self.assertEqual(1, len(payload["errors"]["run"])) + run_error = payload["errors"]["run"][0] + self.assertEqual(expected["stage"], run_error["stage"]) + self.assertEqual(expected["errorType"], run_error["errorType"]) + if "kind" in expected: + self.assertEqual(expected["kind"], run_error["kind"]) + self.assertEqual(expected["message"], run_error["message"]) + self.assertEqual("", result.stderr) def test_normalize_item_returns_work_descriptor_fields(self): item = check.normalize_item("https://kakuyomu.jp/works/123/episodes/456") @@ -364,9 +325,14 @@ def test_build_watchlist_entry_uses_stable_comic_action_work_id(self): self.assertEqual("comic-action:13933686331663374228", entry["id"]) self.assertEqual("comic-action", entry["source"]) - def test_build_watchlist_entry_canonicalizes_comicborder_episode_seed_to_rss(self): - fake_client = mock.Mock() - fake_client.get_text.return_value = """ + def test_build_watchlist_entry_canonicalizes_feed_family_episode_seed_to_rss(self): + cases = [ + ( + "comicborder", + "comicborder.com", + "12207421983437812169", + "12207421983437805229", + """ @@ -375,27 +341,14 @@ def test_build_watchlist_entry_canonicalizes_comicborder_episode_seed_to_rss(sel
- """ - with mock.patch( - "manga_watch.check.normalize_item", - return_value={ - "source": "comicborder", - "workId": "https://comicborder.com/episode/12207421983437812169", - "seedUrl": "https://comicborder.com/episode/12207421983437812169", - }, - ): - entry = check.build_watchlist_entry( - "https://comicborder.com/episode/12207421983437812169", - http_client=fake_client, - ) - - self.assertEqual("comicborder:12207421983437805229", entry["id"]) - self.assertEqual("https://comicborder.com/rss/series/12207421983437805229", entry["seed_url"]) - self.assertEqual("comicborder", entry["source"]) - - def test_build_watchlist_entry_canonicalizes_comic_earthstar_episode_seed_to_rss(self): - fake_client = mock.Mock() - fake_client.get_text.return_value = """ + """, + ), + ( + "comic-earthstar", + "comic-earthstar.com", + "12207421983526541742", + "12207421983526538413", + """ @@ -404,27 +357,14 @@ def test_build_watchlist_entry_canonicalizes_comic_earthstar_episode_seed_to_rss
- """ - with mock.patch( - "manga_watch.check.normalize_item", - return_value={ - "source": "comic-earthstar", - "workId": "https://comic-earthstar.com/episode/12207421983526541742", - "seedUrl": "https://comic-earthstar.com/episode/12207421983526541742", - }, - ): - entry = check.build_watchlist_entry( - "https://comic-earthstar.com/episode/12207421983526541742", - http_client=fake_client, - ) - - self.assertEqual("comic-earthstar:12207421983526538413", entry["id"]) - self.assertEqual("https://comic-earthstar.com/rss/series/12207421983526538413", entry["seed_url"]) - self.assertEqual("comic-earthstar", entry["source"]) - - def test_build_watchlist_entry_canonicalizes_comic_trail_episode_seed_to_rss(self): - fake_client = mock.Mock() - fake_client.get_text.return_value = """ + """, + ), + ( + "comic-trail", + "comic-trail.com", + "2550689798402927313", + "14079602755560047206", + """ @@ -435,27 +375,14 @@ def test_build_watchlist_entry_canonicalizes_comic_trail_episode_seed_to_rss(sel - """ - with mock.patch( - "manga_watch.check.normalize_item", - return_value={ - "source": "comic-trail", - "workId": "https://comic-trail.com/episode/2550689798402927313", - "seedUrl": "https://comic-trail.com/episode/2550689798402927313", - }, - ): - entry = check.build_watchlist_entry( - "https://comic-trail.com/episode/2550689798402927313", - http_client=fake_client, - ) - - self.assertEqual("comic-trail:14079602755560047206", entry["id"]) - self.assertEqual("https://comic-trail.com/rss/series/14079602755560047206", entry["seed_url"]) - self.assertEqual("comic-trail", entry["source"]) - - def test_build_watchlist_entry_canonicalizes_comic_days_episode_seed_to_rss(self): - fake_client = mock.Mock() - fake_client.get_text.return_value = """ + """, + ), + ( + "comic-days", + "comic-days.com", + "12207421983746014850", + "13933686331650127004", + """ @@ -466,27 +393,14 @@ def test_build_watchlist_entry_canonicalizes_comic_days_episode_seed_to_rss(self - """ - with mock.patch( - "manga_watch.check.normalize_item", - return_value={ - "source": "comic-days", - "workId": "https://comic-days.com/episode/12207421983746014850", - "seedUrl": "https://comic-days.com/episode/12207421983746014850", - }, - ): - entry = check.build_watchlist_entry( - "https://comic-days.com/episode/12207421983746014850", - http_client=fake_client, - ) - - self.assertEqual("comic-days:13933686331650127004", entry["id"]) - self.assertEqual("https://comic-days.com/rss/series/13933686331650127004", entry["seed_url"]) - self.assertEqual("comic-days", entry["source"]) - - def test_build_watchlist_entry_canonicalizes_kuragebunch_episode_seed_to_rss(self): - fake_client = mock.Mock() - fake_client.get_text.return_value = """ + """, + ), + ( + "kuragebunch", + "kuragebunch.com", + "2550912964856491139", + "2550912964856487532", + """ @@ -495,23 +409,31 @@ def test_build_watchlist_entry_canonicalizes_kuragebunch_episode_seed_to_rss(sel
- """ - with mock.patch( - "manga_watch.check.normalize_item", - return_value={ - "source": "kuragebunch", - "workId": "https://kuragebunch.com/episode/2550912964856491139", - "seedUrl": "https://kuragebunch.com/episode/2550912964856491139", - }, - ): - entry = check.build_watchlist_entry( - "https://kuragebunch.com/episode/2550912964856491139", - http_client=fake_client, - ) + """, + ), + ] - self.assertEqual("kuragebunch:2550912964856487532", entry["id"]) - self.assertEqual("https://kuragebunch.com/rss/series/2550912964856487532", entry["seed_url"]) - self.assertEqual("kuragebunch", entry["source"]) + for source, host, episode_id, series_id, page_html in cases: + with self.subTest(source=source): + episode_url = f"https://{host}/episode/{episode_id}" + fake_client = mock.Mock() + fake_client.get_text.return_value = page_html + with mock.patch( + "manga_watch.check.normalize_item", + return_value={ + "source": source, + "workId": episode_url, + "seedUrl": episode_url, + }, + ): + entry = check.build_watchlist_entry( + episode_url, + http_client=fake_client, + ) + + self.assertEqual(f"{source}:{series_id}", entry["id"]) + self.assertEqual(f"https://{host}/rss/series/{series_id}", entry["seed_url"]) + self.assertEqual(source, entry["source"]) def test_build_watchlist_entry_uses_stable_champion_cross_work_id(self): fake_client = mock.Mock() fake_client.get_text.return_value = """ @@ -645,62 +567,174 @@ def test_evaluate_notification_policy_rejects_unknown_allowed_update_type(self): update_type="main_story", ) - def test_evaluate_notification_policy_truth_table(self): + def test_notification_policy_truth_table(self): + previous = { + "latest": { + "source": "fake", + "work_id": "work-1", + "latest_key": "ep-1", + "series_title": "作品A", + "episode_title": "第1話", + "url": "https://example.com/work/1", + }, + "history": [], + "unread": {"event_ids": []}, + "health": { + "last_checked_at": 10, + "last_success_at": 10, + "consecutive_failures": 0, + }, + } cases = [ ( + "mode=all bypasses suppressed defaults", {"mode": "all", "allowed_update_types": None}, "bonus", + False, True, "mode", + None, "mode=all notifies every update_type", ), ( + "mode=important_only allows main_story", {"mode": "important_only", "allowed_update_types": None}, "main_story", True, + True, "mode", + None, "mode=important_only allows main_story", ), ( + "mode=important_only suppresses announcement", {"mode": "important_only", "allowed_update_types": None}, "announcement", False, + False, "mode", + None, "mode=important_only suppresses announcement", ), ( + "mode=mute suppresses unknown", {"mode": "mute", "allowed_update_types": None}, "unknown", + True, False, "mode", + None, "mode=mute suppresses every update_type", ), ( + "allowed_update_types override matched announcement", {"mode": "mute", "allowed_update_types": ["announcement"]}, "announcement", + False, True, "allowed_update_types", + ["announcement"], "allowed_update_types override matched announcement", ), ( + "allowed_update_types override did not include bonus", {"mode": "all", "allowed_update_types": ["main_story"]}, "bonus", False, + False, "allowed_update_types", + ["main_story"], "allowed_update_types override did not include bonus", ), + ( + "mode=important_only allows unknown", + {"mode": "important_only", "allowed_update_types": None}, + "unknown", + True, + True, + "mode", + None, + "mode=important_only allows unknown", + ), + ( + "mode=important_only suppresses bonus", + {"mode": "important_only", "allowed_update_types": None}, + "bonus", + False, + False, + "mode", + None, + "mode=important_only suppresses bonus", + ), + ( + "mode=mute suppresses everything", + {"mode": "mute", "allowed_update_types": None}, + "main_story", + True, + False, + "mode", + None, + "mode=mute suppresses every update_type", + ), + ( + "allowed_update_types overrides mute", + {"mode": "mute", "allowed_update_types": ["bonus"]}, + "bonus", + False, + True, + "allowed_update_types", + ["bonus"], + "allowed_update_types override matched bonus", + ), + ( + "empty allowed_update_types overrides all", + {"mode": "all", "allowed_update_types": []}, + "main_story", + True, + False, + "allowed_update_types", + [], + "allowed_update_types override did not include main_story", + ), ] - for policy, update_type, should_notify, applied_via, reason in cases: - with self.subTest(policy=policy, update_type=update_type): + for description, policy, update_type, default_notify, should_notify, applied_via, allowed, reason in cases: + with self.subTest(description=description): decision = evaluate_notification_policy(policy, update_type=update_type) self.assertEqual(policy["mode"], decision["mode"]) - self.assertEqual(policy["allowed_update_types"], decision["allowed_update_types"]) + self.assertEqual(allowed, decision["allowed_update_types"]) self.assertEqual(should_notify, decision["should_notify"]) self.assertEqual(applied_via, decision["applied_via"]) self.assertEqual(reason, decision["reason"]) + latest = { + "source": "fake", + "workId": "work-1", + "latestKey": f"ep-{update_type}", + "seriesTitle": "作品A", + "episodeTitle": f"{update_type} update", + "url": f"https://example.com/{update_type}", + "update_type": update_type, + "default_notify": default_notify, + } + next_entry, update = check.apply_item_transition( + "work-1", + previous, + latest, + seen_at=20, + history_retention=5, + notification_policy=policy, + ) + + self.assertIsNotNone(update) + self.assertEqual(1, len(next_entry["history"])) + self.assertEqual(["ep-" + update_type], next_entry["unread"]["event_ids"]) + self.assertEqual(policy["mode"], update["notification"]["mode"]) + self.assertEqual(allowed, update["notification"]["allowed_update_types"]) + self.assertEqual(should_notify, update["notification"]["should_notify"]) + self.assertEqual(applied_via, update["notification"]["applied_via"]) + def test_run_check_initializes_state_without_updates(self): with tempfile.TemporaryDirectory() as tmpdir: watchlist_path = Path(tmpdir) / "watchlist.json" @@ -1084,120 +1118,6 @@ def test_previous_series_metadata_searches_past_recent_history_without_title(sel check.previous_series_metadata(previous), ) - def test_apply_item_transition_evaluates_notification_policy_truth_table(self): - previous = { - "latest": { - "source": "fake", - "work_id": "work-1", - "latest_key": "ep-1", - "series_title": "作品A", - "episode_title": "第1話", - "url": "https://example.com/work/1", - }, - "history": [], - "unread": {"event_ids": []}, - "health": { - "last_checked_at": 10, - "last_success_at": 10, - "consecutive_failures": 0, - }, - } - cases = [ - ( - "mode=all bypasses suppressed defaults", - {"mode": "all", "allowed_update_types": None}, - "bonus", - False, - True, - "mode", - None, - ), - ( - "mode=important_only allows main_story", - {"mode": "important_only", "allowed_update_types": None}, - "main_story", - True, - True, - "mode", - None, - ), - ( - "mode=important_only allows unknown", - {"mode": "important_only", "allowed_update_types": None}, - "unknown", - True, - True, - "mode", - None, - ), - ( - "mode=important_only suppresses bonus", - {"mode": "important_only", "allowed_update_types": None}, - "bonus", - False, - False, - "mode", - None, - ), - ( - "mode=mute suppresses everything", - {"mode": "mute", "allowed_update_types": None}, - "main_story", - True, - False, - "mode", - None, - ), - ( - "allowed_update_types overrides mute", - {"mode": "mute", "allowed_update_types": ["bonus"]}, - "bonus", - False, - True, - "allowed_update_types", - ["bonus"], - ), - ( - "empty allowed_update_types overrides all", - {"mode": "all", "allowed_update_types": []}, - "main_story", - True, - False, - "allowed_update_types", - [], - ), - ] - - for description, policy, update_type, default_notify, should_notify, applied_via, allowed in cases: - latest = { - "source": "fake", - "workId": "work-1", - "latestKey": f"ep-{update_type}", - "seriesTitle": "作品A", - "episodeTitle": f"{update_type} update", - "url": f"https://example.com/{update_type}", - "update_type": update_type, - "default_notify": default_notify, - } - - with self.subTest(description=description): - next_entry, update = check.apply_item_transition( - "work-1", - previous, - latest, - seen_at=20, - history_retention=5, - notification_policy=policy, - ) - - self.assertIsNotNone(update) - self.assertEqual(1, len(next_entry["history"])) - self.assertEqual(["ep-" + update_type], next_entry["unread"]["event_ids"]) - self.assertEqual(policy["mode"], update["notification"]["mode"]) - self.assertEqual(allowed, update["notification"]["allowed_update_types"]) - self.assertEqual(should_notify, update["notification"]["should_notify"]) - self.assertEqual(applied_via, update["notification"]["applied_via"]) - def test_run_check_keeps_suppressed_updates_in_state_and_machine_readable_output(self): with tempfile.TemporaryDirectory() as tmpdir: watchlist_path = Path(tmpdir) / "watchlist.json" @@ -1900,39 +1820,34 @@ def test_requests_http_client_limits_same_host_concurrency(self): self.assertEqual(1, tracker["max"]) - def test_decode_response_bytes_utf8_without_charset_param(self): - title = "科学的に存在しうるクリーチャー娘の観察日誌" - decoded = decode_response_bytes( - title.encode("utf-8"), - content_type="text/xml", - apparent_encoding=None, - ) - self.assertEqual(title, decoded) - - def test_decode_response_bytes_utf8_with_explicit_charset(self): + def test_decode_response_bytes_decodes_utf8(self): title = "科学的に存在しうるクリーチャー娘の観察日誌" - decoded = decode_response_bytes( - title.encode("utf-8"), - content_type="text/html; charset=utf-8", - apparent_encoding=None, - ) - self.assertEqual(title, decoded) + for case, content_type in ( + ("without_charset_param", "text/xml"), + ("with_explicit_charset", "text/html; charset=utf-8"), + ): + with self.subTest(case=case): + decoded = decode_response_bytes( + title.encode("utf-8"), + content_type=content_type, + apparent_encoding=None, + ) + self.assertEqual(title, decoded) - def test_decode_response_bytes_latin1_charset(self): - decoded = decode_response_bytes( - "caf\xe9".encode("latin-1"), - content_type="text/plain; charset=iso-8859-1", - apparent_encoding=None, - ) - self.assertEqual("café", decoded) + def test_decode_response_bytes_decodes_declared_charset_and_bare_ascii(self): + cases = [ + ("latin1", "café".encode("latin-1"), "text/plain; charset=iso-8859-1", "café"), + ("ascii_without_content_type", b"hello world", None, "hello world"), + ] - def test_decode_response_bytes_ascii_without_content_type(self): - decoded = decode_response_bytes( - b"hello world", - content_type=None, - apparent_encoding=None, - ) - self.assertEqual("hello world", decoded) + for case, raw, content_type, expected in cases: + with self.subTest(case=case): + decoded = decode_response_bytes( + raw, + content_type=content_type, + apparent_encoding=None, + ) + self.assertEqual(expected, decoded) def test_decode_response_bytes_unknown_charset_falls_back_to_utf8(self): title = "科学的に存在しうるクリーチャー娘の観察日誌" diff --git a/tests/test_discord_add.py b/tests/test_discord_add.py index 97130c4..3e756ed 100644 --- a/tests/test_discord_add.py +++ b/tests/test_discord_add.py @@ -70,150 +70,162 @@ def add_subscription(_url, *, watchlist_path=None): self.assertIn("追加できませんでした", payload["content"]) self.assertIn("Unsupported source host: example.com", payload["content"]) - def test_start_accepts_nicovideo_sp_comic_url_via_watchlist_logic(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - watchlist_path.write_text( - json.dumps({"version": 2, "works": []}, ensure_ascii=False), - encoding="utf-8", - ) + def test_start_source_and_issue_report_branch_matrix(self): + def run_accepts_nicovideo_sp_comic_url_via_watchlist_logic(test): + with tempfile.TemporaryDirectory() as tmpdir: + watchlist_path = Path(tmpdir) / "watchlist.json" + watchlist_path.write_text( + json.dumps({"version": 2, "works": []}, ensure_ascii=False), + encoding="utf-8", + ) - handler = AddCommandHandler( - add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url( - url, - watchlist_path=watchlist_path, + handler = AddCommandHandler( + add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url( + url, + watchlist_path=watchlist_path, + ) ) - ) - payload = handler.start( - url="https://sp.manga.nicovideo.jp/comic/53764?track=share", - watchlist_path=str(watchlist_path), - ) + payload = handler.start( + url="https://sp.manga.nicovideo.jp/comic/53764?track=share", + watchlist_path=str(watchlist_path), + ) - self.assertIn("追加しました", payload["content"]) - self.assertIn("nicovideo-manga:53764", payload["content"]) - - def test_start_accepts_firecross_reader_url_via_watchlist_logic(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - watchlist_path.write_text( - json.dumps({"version": 2, "works": []}, ensure_ascii=False), - encoding="utf-8", - ) + test.assertIn("追加しました", payload["content"]) + test.assertIn("nicovideo-manga:53764", payload["content"]) - handler = AddCommandHandler( - add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url( - url, - watchlist_path=watchlist_path, - http_client=type( - "StaticHttpClient", - (), - { - "get_text": lambda self, request_url: { - "https://firecross.jp/reader/19386": """ - - - 作品詳細 - - - """ - }[request_url] - }, - )(), + def run_accepts_firecross_reader_url_via_watchlist_logic(test): + with tempfile.TemporaryDirectory() as tmpdir: + watchlist_path = Path(tmpdir) / "watchlist.json" + watchlist_path.write_text( + json.dumps({"version": 2, "works": []}, ensure_ascii=False), + encoding="utf-8", ) - ) - - payload = handler.start( - url="https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0", - watchlist_path=str(watchlist_path), - ) - self.assertIn("追加しました", payload["content"]) - self.assertIn("firecross:series-abc", payload["content"]) + handler = AddCommandHandler( + add_subscription=lambda url, *, watchlist_path=None: add_watchlist_url( + url, + watchlist_path=watchlist_path, + http_client=type( + "StaticHttpClient", + (), + { + "get_text": lambda self, request_url: { + "https://firecross.jp/reader/19386": """ + + + 作品詳細 + + + """ + }[request_url] + }, + )(), + ) + ) - def test_start_reports_unsupported_source_to_github_issue(self): - calls = [] + payload = handler.start( + url="https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0", + watchlist_path=str(watchlist_path), + ) - class FakeIssueReporter: - def report_unsupported_source(self, *, url, error): - calls.append({"url": url, "error": error}) - return { - "action": "created", - "issue_number": 174, - "issue_url": "https://github.com/kentoku24/comic_crawler/issues/174", - } + test.assertIn("追加しました", payload["content"]) + test.assertIn("firecross:series-abc", payload["content"]) + + def run_reports_unsupported_source_to_github_issue(test): + calls = [] + + class FakeIssueReporter: + def report_unsupported_source(self, *, url, error): + calls.append({"url": url, "error": error}) + return { + "action": "created", + "issue_number": 174, + "issue_url": "https://github.com/kentoku24/comic_crawler/issues/174", + } + + def add_subscription(_url, *, watchlist_path=None): + raise WatchlistAddError( + "unsupported_source", + "Unsupported source host: example.com", + "Use one of the supported sources.", + ) - def add_subscription(_url, *, watchlist_path=None): - raise WatchlistAddError( - "unsupported_source", - "Unsupported source host: example.com", - "Use one of the supported sources.", + handler = AddCommandHandler( + add_subscription=add_subscription, + unsupported_source_reporter=FakeIssueReporter(), ) - handler = AddCommandHandler( - add_subscription=add_subscription, - unsupported_source_reporter=FakeIssueReporter(), - ) + payload = handler.start(url="https://example.com/work/1") - payload = handler.start(url="https://example.com/work/1") + test.assertEqual("https://example.com/work/1", calls[0]["url"]) + test.assertEqual("unsupported_source", calls[0]["error"].kind) + test.assertIn("追加できませんでした", payload["content"]) + test.assertIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"]) + test.assertIn("#174", payload["content"]) - self.assertEqual("https://example.com/work/1", calls[0]["url"]) - self.assertEqual("unsupported_source", calls[0]["error"].kind) - self.assertIn("追加できませんでした", payload["content"]) - self.assertIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"]) - self.assertIn("#174", payload["content"]) + def run_reports_issue_creation_failure_for_unsupported_source(test): + class FailingIssueReporter: + def report_unsupported_source(self, *, url, error): + raise RuntimeError("GitHub issue creation failed") - def test_start_reports_issue_creation_failure_for_unsupported_source(self): - class FailingIssueReporter: - def report_unsupported_source(self, *, url, error): - raise RuntimeError("GitHub issue creation failed") + def add_subscription(_url, *, watchlist_path=None): + raise WatchlistAddError( + "unsupported_source", + "Unsupported source host: example.com", + "Use one of the supported sources.", + ) - def add_subscription(_url, *, watchlist_path=None): - raise WatchlistAddError( - "unsupported_source", - "Unsupported source host: example.com", - "Use one of the supported sources.", + handler = AddCommandHandler( + add_subscription=add_subscription, + unsupported_source_reporter=FailingIssueReporter(), ) - handler = AddCommandHandler( - add_subscription=add_subscription, - unsupported_source_reporter=FailingIssueReporter(), - ) + payload = handler.start(url="https://example.com/work/1") - payload = handler.start(url="https://example.com/work/1") + test.assertIn("追加できませんでした", payload["content"]) + test.assertIn("Unsupported source host: example.com", payload["content"]) + test.assertIn(UNSUPPORTED_SOURCE_REPORT_FAILURE_MESSAGE, payload["content"]) - self.assertIn("追加できませんでした", payload["content"]) - self.assertIn("Unsupported source host: example.com", payload["content"]) - self.assertIn(UNSUPPORTED_SOURCE_REPORT_FAILURE_MESSAGE, payload["content"]) + def run_does_not_report_supported_source_url_type_errors(test): + class RecordingIssueReporter: + def __init__(self): + self.calls = 0 - def test_start_does_not_report_supported_source_url_type_errors(self): - class RecordingIssueReporter: - def __init__(self): - self.calls = 0 + def report_unsupported_source(self, *, url, error): + self.calls += 1 + return {} - def report_unsupported_source(self, *, url, error): - self.calls += 1 - return {} + reporter = RecordingIssueReporter() - reporter = RecordingIssueReporter() + def add_subscription(_url, *, watchlist_path=None): + raise WatchlistAddError( + "unsupported_url_type", + "comic-action does not support this URL type", + "Supported input types for comic-action: episode URL, series feed URL.", + ) - def add_subscription(_url, *, watchlist_path=None): - raise WatchlistAddError( - "unsupported_url_type", - "comic-action does not support this URL type", - "Supported input types for comic-action: episode URL, series feed URL.", + handler = AddCommandHandler( + add_subscription=add_subscription, + unsupported_source_reporter=reporter, ) - handler = AddCommandHandler( - add_subscription=add_subscription, - unsupported_source_reporter=reporter, - ) - - payload = handler.start(url="https://comic-action.com/series/123") - - self.assertEqual(0, reporter.calls) - self.assertIn("追加できませんでした", payload["content"]) - self.assertNotIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"]) + payload = handler.start(url="https://comic-action.com/series/123") + + test.assertEqual(0, reporter.calls) + test.assertIn("追加できませんでした", payload["content"]) + test.assertNotIn(UNSUPPORTED_SOURCE_REPORTED_MESSAGE, payload["content"]) + + cases = [ + ("nicovideo_sp_comic_url", run_accepts_nicovideo_sp_comic_url_via_watchlist_logic), + ("firecross_reader_url", run_accepts_firecross_reader_url_via_watchlist_logic), + ("unsupported_source_reported", run_reports_unsupported_source_to_github_issue), + ("issue_creation_failure", run_reports_issue_creation_failure_for_unsupported_source), + ("url_type_error_not_reported", run_does_not_report_supported_source_url_type_errors), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) if __name__ == "__main__": diff --git a/tests/test_discord_command_registration.py b/tests/test_discord_command_registration.py index 3f9737d..6b796a4 100644 --- a/tests/test_discord_command_registration.py +++ b/tests/test_discord_command_registration.py @@ -2,14 +2,6 @@ import unittest from unittest import mock -from manga_watch.discord_fetch import FETCH_COMMAND -from manga_watch.discord_latest import LATEST_COMMAND -from manga_watch.discord_remove import REMOVE_COMMAND -from manga_watch.discord_search import SEARCH_COMMAND -from manga_watch.discord_supertwins_manage import SUPERTWINS_MANAGE_COMMAND -from manga_watch.discord_supertwins_search import SUPERTWINS_SEARCH_COMMAND -from manga_watch.discord_where import WHERE_COMMAND - class FakeResponse: def __init__(self, status_code=200, text="", json_data=None): @@ -46,43 +38,22 @@ def test_default_command_definitions_include_supertwins_commands(self): from manga_watch.discord_command_registration import default_interaction_commands commands = default_interaction_commands() + names = [command["name"] for command in commands] + self.assertEqual(8, len(names)) self.assertEqual( - [ - LATEST_COMMAND, - FETCH_COMMAND, + { + "latest", + "fetch", "add", - SEARCH_COMMAND, - WHERE_COMMAND, - REMOVE_COMMAND, - SUPERTWINS_SEARCH_COMMAND, - SUPERTWINS_MANAGE_COMMAND, - ], - [command["name"] for command in commands], - ) - add_command = commands[2] - self.assertEqual("作品URLを追加してクロール対象に登録します。", add_command["description"]) - self.assertEqual( - [ - { - "type": 3, - "name": "url", - "description": "追加したい作品URL", - "required": True, - } - ], - add_command["options"], - ) - self.assertEqual( - "既存作品を起点に他媒体候補を探して supertwins を作成します。", - commands[6]["description"], - ) - self.assertNotIn("options", commands[6]) - self.assertEqual( - "既存の supertwins を確認して誤登録を解除します。", - commands[7]["description"], + "search", + "where", + "remove", + "supertwins-search", + "supertwins-manage", + }, + set(names), ) - self.assertNotIn("options", commands[7]) def test_ensure_registered_from_env_uses_guild_registration_when_guild_id_is_present(self): from manga_watch.discord_command_registration import ensure_commands_registered_from_env diff --git a/tests/test_discord_command_registration_search.py b/tests/test_discord_command_registration_search.py index 5d231d7..e0c9b24 100644 --- a/tests/test_discord_command_registration_search.py +++ b/tests/test_discord_command_registration_search.py @@ -1,11 +1,5 @@ import unittest -from manga_watch.discord_fetch import FETCH_COMMAND -from manga_watch.discord_latest import LATEST_COMMAND -from manga_watch.discord_remove import REMOVE_COMMAND -from manga_watch.discord_search import SEARCH_COMMAND -from manga_watch.discord_where import WHERE_COMMAND - class DiscordSearchCommandRegistrationTests(unittest.TestCase): def test_default_command_definitions_include_search(self): @@ -13,19 +7,6 @@ def test_default_command_definitions_include_search(self): commands = default_interaction_commands() - self.assertEqual( - [ - LATEST_COMMAND, - FETCH_COMMAND, - "add", - SEARCH_COMMAND, - WHERE_COMMAND, - REMOVE_COMMAND, - "supertwins-search", - "supertwins-manage", - ], - [command["name"] for command in commands], - ) search_command = commands[3] self.assertEqual("媒体ごとに作品名で検索します。", search_command["description"]) self.assertEqual( diff --git a/tests/test_discord_interactions.py b/tests/test_discord_interactions.py index fd52b04..d0794a7 100644 --- a/tests/test_discord_interactions.py +++ b/tests/test_discord_interactions.py @@ -12,7 +12,6 @@ DiscordRequestVerifier, InProcessFetchDispatcher, build_interaction_service_from_env, - build_manual_run_request_body, ) from manga_watch.discord_add import AddCommandHandler from manga_watch.discord_remove import REMOVE_COMMAND @@ -349,18 +348,6 @@ def test_cloud_run_job_fetch_dispatcher_posts_manual_override(self): ) self.assertIn("/jobs/comic-crawler-job:run", session.posts[0]["url"]) - def test_build_manual_run_request_body_uses_manual_alias(self): - self.assertEqual( - { - "overrides": { - "containerOverrides": [ - {"env": [{"name": "MANGA_WATCH_TRIGGER_SOURCE", "value": "manual"}]} - ] - } - }, - build_manual_run_request_body(), - ) - def test_discord_interaction_callback_client_uses_shorter_timeout_for_defer(self): from manga_watch.discord_interactions import DiscordInteractionCallbackClient diff --git a/tests/test_discord_interactions_search.py b/tests/test_discord_interactions_search.py index 839c58b..30525d3 100644 --- a/tests/test_discord_interactions_search.py +++ b/tests/test_discord_interactions_search.py @@ -110,52 +110,6 @@ def test_search_command_routes_to_search_handler(self): self.assertEqual("champion-cross", search_handler.start_calls[0]["source"]) self.assertEqual("まんが", search_handler.start_calls[0]["query"]) - def test_search_command_routes_to_search_handler_without_source(self): - search_handler = RecordingSearchHandler() - callback_client = RecordingInteractionCallbackClient() - service = DiscordInteractionService( - timezone_name="Asia/Tokyo", - fetch_dispatcher=RecordingFetchDispatcher(), - verification_disabled=True, - search_handler=search_handler, - interaction_callback_client=callback_client, - ) - - response = service.handle_request( - method="POST", - path="/", - headers={}, - body=json.dumps(self.signed_command_payload(SEARCH_COMMAND)).encode("utf-8"), - ) - - self.assertEqual(202, response.status_code) - self.assertEqual("検索結果を選んでください。", callback_client.edits[0]["data"]["content"]) - self.assertIsNone(search_handler.start_calls[0]["source"]) - self.assertEqual("まんが", search_handler.start_calls[0]["query"]) - - def test_search_component_routes_to_search_handler(self): - search_handler = RecordingSearchHandler() - service = DiscordInteractionService( - timezone_name="Asia/Tokyo", - fetch_dispatcher=RecordingFetchDispatcher(), - verification_disabled=True, - search_handler=search_handler, - ) - - response = service.handle_request( - method="POST", - path="/", - headers={}, - body=json.dumps( - {"type": 3, "data": {"custom_id": "search_select:visible", "values": ["https://example.com/a"]}} - ).encode("utf-8"), - ) - - payload = json.loads(response.body) - self.assertEqual(7, payload["type"]) - self.assertEqual("追加しました: 作品A", payload["data"]["content"]) - self.assertEqual("search_select:visible", search_handler.component_calls[0]["data"]["custom_id"]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_discord_interactions_where.py b/tests/test_discord_interactions_where.py index d19c965..fb054d5 100644 --- a/tests/test_discord_interactions_where.py +++ b/tests/test_discord_interactions_where.py @@ -86,35 +86,6 @@ def edit_original_response(self, *, application_id: str, interaction_token: str, class DiscordInteractionWhereTests(unittest.TestCase): - def test_where_command_routes_to_where_handler(self): - where_handler = RecordingWhereHandler() - service = DiscordInteractionService( - timezone_name="Asia/Tokyo", - fetch_dispatcher=RecordingFetchDispatcher(), - verification_disabled=True, - where_handler=where_handler, - ) - payload = { - "type": 2, - "data": { - "name": WHERE_COMMAND, - "options": [ - {"name": "query", "type": 3, "value": "ニセモノの錬金術師"}, - {"name": "episode", "type": 3, "value": "第1話"}, - ], - }, - } - - response = service.handle_request(method="POST", path="/", headers={}, body=json.dumps(payload).encode()) - - body = json.loads(response.body) - self.assertEqual(4, body["type"]) - self.assertEqual("where 候補を選んでください。", body["data"]["content"]) - self.assertEqual( - [{"query": "ニセモノの錬金術師", "episode": "第1話"}], - where_handler.start_calls, - ) - def test_where_command_defers_when_callback_client_is_available(self): events = [] where_handler = RecordingWhereHandler(events=events) @@ -162,73 +133,5 @@ def test_where_command_defers_when_callback_client_is_available(self): events, ) - def test_where_component_routes_to_where_handler(self): - where_handler = RecordingWhereHandler() - service = DiscordInteractionService( - timezone_name="Asia/Tokyo", - fetch_dispatcher=RecordingFetchDispatcher(), - verification_disabled=True, - where_handler=where_handler, - ) - - response = service.handle_request( - method="POST", - path="/", - headers={}, - body=json.dumps( - {"type": 3, "data": {"custom_id": "where_select:token", "values": ["0"]}} - ).encode(), - ) - - body = json.loads(response.body) - self.assertEqual(7, body["type"]) - self.assertEqual("ComicWalker: 今すぐ無料", body["data"]["content"]) - self.assertEqual("where_select:token", where_handler.component_calls[0]["data"]["custom_id"]) - - def test_where_component_defers_when_callback_client_is_available(self): - events = [] - where_handler = RecordingWhereHandler(events=events) - callback_client = RecordingInteractionCallbackClient(events=events) - service = DiscordInteractionService( - timezone_name="Asia/Tokyo", - fetch_dispatcher=RecordingFetchDispatcher(), - verification_disabled=True, - where_handler=where_handler, - interaction_callback_client=callback_client, - ) - - response = service.handle_request( - method="POST", - path="/", - headers={}, - body=json.dumps( - { - "id": "interaction-1", - "application_id": "app-1", - "token": "token-1", - "type": 3, - "data": {"custom_id": "where_select:token", "values": ["0"]}, - } - ).encode(), - ) - - self.assertEqual(202, response.status_code) - self.assertEqual(b"", response.body) - self.assertEqual( - [{"interaction_id": "interaction-1", "interaction_token": "token-1"}], - callback_client.deferred_components, - ) - self.assertEqual("ComicWalker: 今すぐ無料", callback_client.edits[0]["data"]["content"]) - self.assertEqual("where_select:token", where_handler.component_calls[0]["data"]["custom_id"]) - self.assertEqual( - [ - "callback.defer_component", - "where.handle_component", - "callback.edit_original_response", - ], - events, - ) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_discord_latest.py b/tests/test_discord_latest.py index a3fe81f..434bf69 100644 --- a/tests/test_discord_latest.py +++ b/tests/test_discord_latest.py @@ -86,304 +86,319 @@ def test_handle_latest_query_routes_only_trimmed_exact_latest(self): ) ) - def test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans(self): - watchlist = self.make_watchlist( - [ - { - "id": "work-b", - "source": "comic-walker", - "seed_url": "https://example.com/work-b", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, + def test_build_latest_query_response_matrix(self): + def run_watchlist_order_ignores_disabled_and_orphans(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-b", + "source": "comic-walker", + "seed_url": "https://example.com/work-b", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + { + "id": "work-a", + "source": "comic-walker", + "seed_url": "https://example.com/work-a", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + { + "id": "work-disabled", + "source": "comic-walker", + "seed_url": "https://example.com/work-disabled", + "enabled": False, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + ] + ) + state = test.make_state( { - "id": "work-a", - "source": "comic-walker", - "seed_url": "https://example.com/work-a", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, + "work-a": { + "latest": {"series_title": "作品A", "episode_title": "第1話", "url": "https://example.com/a"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, + "work-b": { + "latest": {"series_title": "作品B", "episode_title": "第8話", "url": "https://example.com/b"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, + "work-disabled": { + "latest": {"series_title": "作品C", "episode_title": "第9話", "url": "https://example.com/c"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, + "orphan": { + "latest": {"series_title": "孤児", "episode_title": "第99話", "url": "https://example.com/orphan"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, }, + last_run_at=1_700_000_000, + ) + + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + lines = response.splitlines() + + test.assertEqual("現在のリスト:", lines[2]) + test.assertEqual("[第8話]() 作品B", lines[3]) + test.assertEqual("[第1話]() 作品A", lines[4]) + test.assertNotIn("作品C", response) + test.assertNotIn("孤児", response) + + def run_excludes_hidden_entries(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-visible", + "source": "comic-walker", + "seed_url": "https://example.com/work-visible", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + { + "id": "work-hidden", + "source": "comic-walker", + "seed_url": "https://example.com/work-hidden", + "enabled": True, + "hidden": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + ] + ) + state = test.make_state( { - "id": "work-disabled", - "source": "comic-walker", - "seed_url": "https://example.com/work-disabled", - "enabled": False, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, - ] - ) - state = self.make_state( - { - "work-a": { - "latest": {"series_title": "作品A", "episode_title": "第1話", "url": "https://example.com/a"}, - "history": [], - "health": {"consecutive_failures": 0}, - }, - "work-b": { - "latest": {"series_title": "作品B", "episode_title": "第8話", "url": "https://example.com/b"}, - "history": [], - "health": {"consecutive_failures": 0}, - }, - "work-disabled": { - "latest": {"series_title": "作品C", "episode_title": "第9話", "url": "https://example.com/c"}, - "history": [], - "health": {"consecutive_failures": 0}, - }, - "orphan": { - "latest": {"series_title": "孤児", "episode_title": "第99話", "url": "https://example.com/orphan"}, - "history": [], - "health": {"consecutive_failures": 0}, + "work-visible": { + "latest": {"series_title": "表示作品", "episode_title": "第3話", "url": "https://example.com/visible"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, + "work-hidden": { + "latest": {"series_title": "非表示作品", "episode_title": "第9話", "url": "https://example.com/hidden"}, + "history": [], + "health": {"consecutive_failures": 0}, + }, }, - }, - last_run_at=1_700_000_000, - ) + last_run_at=1_700_000_000, + ) - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - lines = response.splitlines() + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - self.assertEqual("現在のリスト:", lines[2]) - self.assertEqual("[第8話]() 作品B", lines[3]) - self.assertEqual("[第1話]() 作品A", lines[4]) - self.assertNotIn("作品C", response) - self.assertNotIn("孤児", response) + test.assertIn("表示作品", response) + test.assertNotIn("非表示作品", response) - def test_build_latest_query_response_excludes_hidden_entries(self): - watchlist = self.make_watchlist( - [ - { - "id": "work-visible", - "source": "comic-walker", - "seed_url": "https://example.com/work-visible", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, + def run_empty_message_when_all_works_are_unfetched(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + { + "id": "work-2", + "source": "comic-walker", + "seed_url": "https://example.com/work-2", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + ] + ) + state = test.make_state( { - "id": "work-hidden", - "source": "comic-walker", - "seed_url": "https://example.com/work-hidden", - "enabled": True, - "hidden": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, - ] - ) - state = self.make_state( - { - "work-visible": { - "latest": {"series_title": "表示作品", "episode_title": "第3話", "url": "https://example.com/visible"}, - "history": [], - "health": {"consecutive_failures": 0}, - }, - "work-hidden": { - "latest": {"series_title": "非表示作品", "episode_title": "第9話", "url": "https://example.com/hidden"}, - "history": [], - "health": {"consecutive_failures": 0}, - }, - }, - last_run_at=1_700_000_000, - ) - - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - - self.assertIn("表示作品", response) - self.assertNotIn("非表示作品", response) + "work-1": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, + "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, + } + ) - def test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched(self): - watchlist = self.make_watchlist( - [ + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + + test.assertIn("最終巡回: まだ実行されていません", response) + test.assertTrue(response.endswith(EMPTY_LINE)) + test.assertNotIn("(未取得)", response) + + def run_unfetched_rows_when_mixed_with_saved_results(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + { + "id": "work-2", + "source": "comic-walker", + "seed_url": "https://example.com/work-2", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + }, + ] + ) + state = test.make_state( { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, + "work-1": { + "latest": { + "series_title": "作品A", + "episode_title": "第71話 abcdefghijk", + "url": "https://example.com/71", + }, + "history": [], + "health": {"consecutive_failures": 1}, + }, + "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, + } + ) + + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + + test.assertIn("[第71話 abcdefg…]() 作品A", response) + test.assertIn("(未取得) work-2", response) + test.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING)) + + def run_saved_next_update_label(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + } + ] + ) + state = test.make_state( { - "id": "work-2", - "source": "comic-walker", - "seed_url": "https://example.com/work-2", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, + "work-1": { + "latest": { + "series_title": "作品A", + "episode_title": "第2話", + "next_update_label": "次回更新予定 3/15", + "url": "https://example.com/2", + }, + "history": [], + "health": {"consecutive_failures": 0}, + } }, - ] - ) - state = self.make_state( - { - "work-1": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, - "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, - } - ) + last_run_at=1_700_000_000, + ) - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - self.assertIn("最終巡回: まだ実行されていません", response) - self.assertTrue(response.endswith(EMPTY_LINE)) - self.assertNotIn("(未取得)", response) + test.assertIn( + "[第2話(次回更新予定 3/15)]() 作品A", + response, + ) - def test_build_latest_query_response_includes_unfetched_rows_when_mixed_with_saved_results(self): - watchlist = self.make_watchlist( - [ + def run_plain_text_and_fallback_labels_without_url(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + } + ] + ) + state = test.make_state( { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, + "work-1": { + "latest": { + "series": "作品A fallback", + "episode_code": "ep-2", + }, + "history": [], + "health": {"consecutive_failures": 0}, + } }, + last_run_at=1_700_000_000, + ) + + response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + + test.assertIn("ep-2 作品A fallback", response) + test.assertNotIn("[ep-2]", response) + + def run_stale_saved_data_warning(test): + watchlist = test.make_watchlist( + [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "health_policy": {"expected_interval_seconds": 3_600}, + "notification_policy": {"mode": "all", "allowed_update_types": None}, + } + ] + ) + state = test.make_state( { - "id": "work-2", - "source": "comic-walker", - "seed_url": "https://example.com/work-2", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - }, - ] - ) - state = self.make_state( - { - "work-1": { - "latest": { - "series_title": "作品A", - "episode_title": "第71話 abcdefghijk", - "url": "https://example.com/71", - }, - "history": [], - "health": {"consecutive_failures": 1}, + "work-1": { + "latest": { + "series_title": "作品A", + "episode_title": "第2話", + "url": "https://example.com/2", + }, + "history": [], + "health": { + "last_checked_at": 1_700_010_000, + "last_success_at": 1_700_000_000, + "consecutive_failures": 0, + }, + } }, - "work-2": {"latest": {}, "history": [], "health": {"consecutive_failures": 0}}, - } - ) - - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") + last_run_at=1_700_010_000, + ) - self.assertIn("[第71話 abcdefg…]() 作品A", response) - self.assertIn("(未取得) work-2", response) - self.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING)) + response = build_latest_query_response( + watchlist, + state, + timezone_name="Asia/Tokyo", + now=1_700_008_000, + ) - def test_build_latest_query_response_appends_saved_next_update_label(self): - watchlist = self.make_watchlist( - [ - { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - } - ] - ) - state = self.make_state( - { - "work-1": { - "latest": { - "series_title": "作品A", - "episode_title": "第2話", + test.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING)) + + def run_episode_label_truncation_spec_examples(test): + test.assertEqual("第71話 abcdefg…", truncate_episode_label("第71話 abcdefghijk")) + test.assertEqual("第71話 あいうえおかき…", truncate_episode_label("第71話 あいうえおかきくけ")) + test.assertEqual("第71話 abあいうcd…", truncate_episode_label("第71話 abあいうcdef")) + test.assertEqual("abcdefghijklmnopqrs…", truncate_episode_label("abcdefghijklmnopqrstu")) + test.assertEqual("第55話後編", truncate_episode_label("第55話後編")) + test.assertEqual("[第71話 abcdefg…]()", format_discord_link("第71話 abcdefghijk", "https://example.com/71")) + test.assertEqual( + "第71話 abcdefg…(次回更新予定 3/15)", + latest_display_label_for_snapshot( + { + "episode_title": "第71話 abcdefghijk", "next_update_label": "次回更新予定 3/15", - "url": "https://example.com/2", - }, - "history": [], - "health": {"consecutive_failures": 0}, - } - }, - last_run_at=1_700_000_000, - ) - - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - - self.assertIn( - "[第2話(次回更新予定 3/15)]() 作品A", - response, - ) - - def test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url(self): - watchlist = self.make_watchlist( - [ - { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - } - ] - ) - state = self.make_state( - { - "work-1": { - "latest": { - "series": "作品A fallback", - "episode_code": "ep-2", }, - "history": [], - "health": {"consecutive_failures": 0}, - } - }, - last_run_at=1_700_000_000, - ) - - response = build_latest_query_response(watchlist, state, timezone_name="Asia/Tokyo") - - self.assertIn("ep-2 作品A fallback", response) - self.assertNotIn("[ep-2]", response) - - def test_build_latest_query_response_warns_for_stale_saved_data(self): - watchlist = self.make_watchlist( - [ - { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "health_policy": {"expected_interval_seconds": 3_600}, - "notification_policy": {"mode": "all", "allowed_update_types": None}, - } - ] - ) - state = self.make_state( - { - "work-1": { - "latest": { - "series_title": "作品A", - "episode_title": "第2話", - "url": "https://example.com/2", - }, - "history": [], - "health": { - "last_checked_at": 1_700_010_000, - "last_success_at": 1_700_000_000, - "consecutive_failures": 0, - }, - } - }, - last_run_at=1_700_010_000, - ) - - response = build_latest_query_response( - watchlist, - state, - timezone_name="Asia/Tokyo", - now=1_700_008_000, - ) + truncate_episode=True, + ), + ) - self.assertTrue(response.endswith(PARTIAL_FAILURE_WARNING)) - - def test_episode_label_truncation_matches_spec_examples(self): - self.assertEqual("第71話 abcdefg…", truncate_episode_label("第71話 abcdefghijk")) - self.assertEqual("第71話 あいうえおかき…", truncate_episode_label("第71話 あいうえおかきくけ")) - self.assertEqual("第71話 abあいうcd…", truncate_episode_label("第71話 abあいうcdef")) - self.assertEqual("abcdefghijklmnopqrs…", truncate_episode_label("abcdefghijklmnopqrstu")) - self.assertEqual("第55話後編", truncate_episode_label("第55話後編")) - self.assertEqual("[第71話 abcdefg…]()", format_discord_link("第71話 abcdefghijk", "https://example.com/71")) - self.assertEqual( - "第71話 abcdefg…(次回更新予定 3/15)", - latest_display_label_for_snapshot( - { - "episode_title": "第71話 abcdefghijk", - "next_update_label": "次回更新予定 3/15", - }, - truncate_episode=True, - ), - ) + cases = [ + ("watchlist_order_ignores_disabled_and_orphans", run_watchlist_order_ignores_disabled_and_orphans), + ("excludes_hidden_entries", run_excludes_hidden_entries), + ("empty_message_when_all_works_unfetched", run_empty_message_when_all_works_are_unfetched), + ("unfetched_rows_mixed_with_saved_results", run_unfetched_rows_when_mixed_with_saved_results), + ("saved_next_update_label", run_saved_next_update_label), + ("plain_text_and_fallback_labels_without_url", run_plain_text_and_fallback_labels_without_url), + ("stale_saved_data_warning", run_stale_saved_data_warning), + ("episode_label_truncation_spec_examples", run_episode_label_truncation_spec_examples), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) def test_handle_latest_query_is_read_only_and_uses_only_injected_loaders(self): watchlist = self.make_watchlist( diff --git a/tests/test_discord_outbound.py b/tests/test_discord_outbound.py index 722401b..1005c3d 100644 --- a/tests/test_discord_outbound.py +++ b/tests/test_discord_outbound.py @@ -96,105 +96,115 @@ def test_build_daily_notification_message_formats_lines_and_truncates_labels(sel self.assertEqual("[作品A:第71話 abcdefg…]()←第1話", lines[1]) self.assertEqual("[作品B:第3話]()←第2話", lines[2]) - def test_enqueue_daily_notification_skips_updates_without_latest_key(self): - state = { - "discord_delivery": { - "daily_notification": { - "delivered_latest_keys": {}, - "pending_messages": [], + def test_enqueue_daily_notification_dedupe_matrix(self): + def run_skips_updates_without_latest_key(test): + state = { + "discord_delivery": { + "daily_notification": { + "delivered_latest_keys": {}, + "pending_messages": [], + } } } - } - update = { - "id": "work-1", - "from": { - "seriesTitle": "作品A", - "episodeTitle": "第1話", - "latestKey": "episode-1", - }, - "to": { - "series_title": "作品A", - "episode_title": "第2話 改題", - "episode_code": "episode-2", - "url": "https://example.com/latest", - }, - } - - result = enqueue_daily_notification( - state, - updates=[update], - channel_id="main-channel", - now_ts=1_700_000_000, - timezone_name="Asia/Tokyo", - created_at="2023-11-14T22:13:20Z", - ) - - self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) - self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) + update = { + "id": "work-1", + "from": { + "seriesTitle": "作品A", + "episodeTitle": "第1話", + "latestKey": "episode-1", + }, + "to": { + "series_title": "作品A", + "episode_title": "第2話 改題", + "episode_code": "episode-2", + "url": "https://example.com/latest", + }, + } - def test_enqueue_daily_notification_dedupes_by_work_id_and_latest_key_even_if_metadata_changes(self): - state = { - "discord_delivery": { - "daily_notification": { - "delivered_latest_keys": { - "work-1": { - "latest_key": "episode-2", - "delivered_at": "2023-11-14T22:13:20Z", - } - }, - "pending_messages": [], + result = enqueue_daily_notification( + state, + updates=[update], + channel_id="main-channel", + now_ts=1_700_000_000, + timezone_name="Asia/Tokyo", + created_at="2023-11-14T22:13:20Z", + ) + + test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) + test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) + + def run_dedupes_by_work_id_and_latest_key_even_if_metadata_changes(test): + state = { + "discord_delivery": { + "daily_notification": { + "delivered_latest_keys": { + "work-1": { + "latest_key": "episode-2", + "delivered_at": "2023-11-14T22:13:20Z", + } + }, + "pending_messages": [], + } } } - } - - result = enqueue_daily_notification( - state, - updates=[ - self.make_update( - episode_title="第2話 改題", - previous="第2話", - ) - ], - channel_id="main-channel", - now_ts=1_700_000_300, - timezone_name="Asia/Tokyo", - created_at="2023-11-14T22:18:20Z", - ) - - self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) - self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) - def test_enqueue_daily_notification_dedupes_against_legacy_camel_case_delivery_state(self): - state = { - "discordDelivery": { - "dailyNotification": { - "deliveredLatestKeys": { - "work-1": { - "latestKey": "episode-2", - "deliveredAt": "2023-11-14T22:13:20Z", - } - }, - "pendingMessages": [], + result = enqueue_daily_notification( + state, + updates=[ + test.make_update( + episode_title="第2話 改題", + previous="第2話", + ) + ], + channel_id="main-channel", + now_ts=1_700_000_300, + timezone_name="Asia/Tokyo", + created_at="2023-11-14T22:18:20Z", + ) + + test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) + test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) + + def run_dedupes_against_legacy_camel_case_delivery_state(test): + state = { + "discordDelivery": { + "dailyNotification": { + "deliveredLatestKeys": { + "work-1": { + "latestKey": "episode-2", + "deliveredAt": "2023-11-14T22:13:20Z", + } + }, + "pendingMessages": [], + } } } - } - result = enqueue_daily_notification( - state, - updates=[ - self.make_update( - episode_title="第2話 改題", - previous="第2話", - ) - ], - channel_id="main-channel", - now_ts=1_700_000_300, - timezone_name="Asia/Tokyo", - created_at="2023-11-14T22:18:20Z", - ) - - self.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) - self.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) + result = enqueue_daily_notification( + state, + updates=[ + test.make_update( + episode_title="第2話 改題", + previous="第2話", + ) + ], + channel_id="main-channel", + now_ts=1_700_000_300, + timezone_name="Asia/Tokyo", + created_at="2023-11-14T22:18:20Z", + ) + + test.assertEqual({"queued": False, "candidateUpdateCount": 0}, result) + test.assertEqual([], state["discord_delivery"]["daily_notification"]["pending_messages"]) + + cases = [ + ("skips_updates_without_latest_key", run_skips_updates_without_latest_key), + ("dedupes_by_work_id_and_latest_key", run_dedupes_by_work_id_and_latest_key_even_if_metadata_changes), + ("dedupes_against_legacy_camel_case_state", run_dedupes_against_legacy_camel_case_delivery_state), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) def test_filter_updates_for_daily_notifications_excludes_hidden_watchlist_entries(self): updates = [ @@ -287,43 +297,52 @@ def test_discord_channel_client_reads_current_user_and_channel_messages(self): self.assertEqual({"limit": 100, "after": "10"}, session.calls[1]["params"]) self.assertEqual("latest", messages[0]["content"]) - def test_discord_channel_client_masks_bot_token_in_transport_error(self): - token = "discord-bot-token" - session = FakeSession(error=requests.Timeout(f"Authorization: Bot {token}")) - client = DiscordChannelClient( - DiscordOutboundConfig( - bot_token=token, - main_channel_id="main-channel", - run_report_channel_id="run-report-channel", - ), - session=session, - ) - - with self.assertRaises(RuntimeError) as exc_info: - client.send_message("run-report-channel", "hello") - - self.assertNotIn(token, str(exc_info.exception)) - self.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception)) + def test_discord_channel_client_masks_bot_token_matrix(self): + def run_masks_bot_token_in_transport_error(test): + token = "discord-bot-token" + session = FakeSession(error=requests.Timeout(f"Authorization: Bot {token}")) + client = DiscordChannelClient( + DiscordOutboundConfig( + bot_token=token, + main_channel_id="main-channel", + run_report_channel_id="run-report-channel", + ), + session=session, + ) + + with test.assertRaises(RuntimeError) as exc_info: + client.send_message("run-report-channel", "hello") + + test.assertNotIn(token, str(exc_info.exception)) + test.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception)) + + def run_masks_bot_token_in_error_response(test): + token = "discord-bot-token" + session = FakeSession( + responses=[FakeResponse(401, text=f"Authorization header was Bot {token}")] + ) + client = DiscordChannelClient( + DiscordOutboundConfig( + bot_token=token, + main_channel_id="main-channel", + run_report_channel_id="run-report-channel", + ), + session=session, + ) - def test_discord_channel_client_masks_bot_token_in_error_response(self): - token = "discord-bot-token" - session = FakeSession( - responses=[FakeResponse(401, text=f"Authorization header was Bot {token}")] - ) - client = DiscordChannelClient( - DiscordOutboundConfig( - bot_token=token, - main_channel_id="main-channel", - run_report_channel_id="run-report-channel", - ), - session=session, - ) + with test.assertRaises(RuntimeError) as exc_info: + client.send_message("run-report-channel", "hello") - with self.assertRaises(RuntimeError) as exc_info: - client.send_message("run-report-channel", "hello") + test.assertNotIn(token, str(exc_info.exception)) + test.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception)) - self.assertNotIn(token, str(exc_info.exception)) - self.assertIn("[REDACTED_BOT_TOKEN]", str(exc_info.exception)) + cases = [ + ("transport_error", run_masks_bot_token_in_transport_error), + ("error_response", run_masks_bot_token_in_error_response), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) def test_state_round_trip_preserves_discord_delivery_state(self): state = { diff --git a/tests/test_discord_remove.py b/tests/test_discord_remove.py index cd5df48..096e601 100644 --- a/tests/test_discord_remove.py +++ b/tests/test_discord_remove.py @@ -11,7 +11,7 @@ build_remove_token, remove_watch_subscription, ) -from tests.test_firestore_storage import FakeFirestoreClient, make_state, make_watchlist +from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist def write_json(path: Path, payload): diff --git a/tests/test_discord_search.py b/tests/test_discord_search.py index b2cad13..ea55d8c 100644 --- a/tests/test_discord_search.py +++ b/tests/test_discord_search.py @@ -1,7 +1,6 @@ import unittest from manga_watch.discord_search import ( - SEARCH_COMMAND, SEARCH_MISSING_SOURCE_MESSAGE, SEARCH_NO_RESULTS_MESSAGE, SearchCommandHandler, @@ -87,9 +86,6 @@ def __call__(self, url, *, watchlist_path=None, hidden=False): class DiscordSearchTests(unittest.TestCase): - def test_search_command_name_is_exported(self): - self.assertEqual("search", SEARCH_COMMAND) - def test_start_returns_ephemeral_select_menu_for_results(self): search_source = FakeSearchSource( [ @@ -196,210 +192,251 @@ def test_start_accepts_missing_source_without_old_error(self): self.assertNotEqual(SEARCH_MISSING_SOURCE_MESSAGE, response["content"]) self.assertEqual([], search_source.calls) - def test_start_aggregates_cross_source_results_with_interleaving_and_dedupe(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="作品A walker 1", - seed_url="https://comic-walker.com/detail/a", - ), - SearchResult( - source="comic-walker", - title="作品A walker duplicate", - seed_url="https://comic-walker.com/detail/a", - ), - SearchResult( - source="comic-walker", - title="作品A walker 2", - seed_url="https://comic-walker.com/detail/b", - ), - ], - "champion-cross": [ - SearchResult( - source="champion-cross", - title="作品B cross 1", - seed_url="https://championcross.jp/series/a", - ), - SearchResult( - source="champion-cross", - title="作品B cross 2", - seed_url="https://championcross.jp/series/b", - ), - ], - "takecomic": [ - SearchResult( - source="takecomic", - title="作品C take 1", - seed_url="https://takecomic.jp/comics/1", - ) - ], - } - ) - handler = SearchCommandHandler( - search_source=search_source, - supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), - ) - - response = handler.start(source=None, query="まんが", visibility="visible") - - self.assertEqual( - [ - { - "source": "comic-walker", - "query": "まんが", - "http_client": None, - "limit": 3, - }, - { - "source": "champion-cross", - "query": "まんが", - "http_client": None, - "limit": 3, - }, + def test_start_cross_source_matrix(self): + def run_interleaving_and_dedupe(test): + search_source = MultiSourceSearchSource( { - "source": "takecomic", - "query": "まんが", - "http_client": None, - "limit": 3, - }, - ], - search_source.calls, - ) - self.assertEqual("横断検索結果です。1件選んでください。", response["content"]) - self.assertEqual( - [ - { - "label": "作品A walker 1", - "value": "https://comic-walker.com/detail/a", - "description": "comic-walker", - }, - { - "label": "作品B cross 1", - "value": "https://championcross.jp/series/a", - "description": "champion-cross", - }, - { - "label": "作品C take 1", - "value": "https://takecomic.jp/comics/1", - "description": "takecomic", - }, - { - "label": "作品A walker 2", - "value": "https://comic-walker.com/detail/b", - "description": "comic-walker", - }, - { - "label": "作品B cross 2", - "value": "https://championcross.jp/series/b", - "description": "champion-cross", - }, - ], - response["components"][0]["components"][0]["options"], - ) - - def test_start_caps_cross_source_results_to_select_limit(self): - sources = tuple(f"source-{index}" for index in range(9)) - search_source = MultiSourceSearchSource( - { - source_name: [ - SearchResult( - source=source_name, - title=f"{source_name}-{index}", - seed_url=f"https://example.com/{source_name}/{index}", - ) - for index in range(10) - ] - for source_name in sources - } - ) - handler = SearchCommandHandler( - search_source=search_source, - supported_sources=lambda: sources, - ) - - response = handler.start(source=None, query="まんが") - - options = response["components"][0]["components"][0]["options"] - self.assertEqual(25, len(options)) - self.assertEqual("source-0-0", options[0]["label"]) - self.assertEqual("source-1-0", options[1]["label"]) - self.assertEqual("source-2-0", options[2]["label"]) - - def test_start_returns_partial_cross_source_results_when_some_sources_fail(self): - search_source = MultiSourceSearchSourceWithFailures( - results_by_source={ - "comic-walker": [ - SearchResult( - source="comic-walker", - title="walker only", - seed_url="https://comic-walker.com/detail/1", - ) + "comic-walker": [ + SearchResult( + source="comic-walker", + title="作品A walker 1", + seed_url="https://comic-walker.com/detail/a", + ), + SearchResult( + source="comic-walker", + title="作品A walker duplicate", + seed_url="https://comic-walker.com/detail/a", + ), + SearchResult( + source="comic-walker", + title="作品A walker 2", + seed_url="https://comic-walker.com/detail/b", + ), + ], + "champion-cross": [ + SearchResult( + source="champion-cross", + title="作品B cross 1", + seed_url="https://championcross.jp/series/a", + ), + SearchResult( + source="champion-cross", + title="作品B cross 2", + seed_url="https://championcross.jp/series/b", + ), + ], + "takecomic": [ + SearchResult( + source="takecomic", + title="作品C take 1", + seed_url="https://takecomic.jp/comics/1", + ) + ], + } + ) + handler = SearchCommandHandler( + search_source=search_source, + supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), + ) + + response = handler.start(source=None, query="まんが", visibility="visible") + + test.assertEqual( + [ + { + "source": "comic-walker", + "query": "まんが", + "http_client": None, + "limit": 3, + }, + { + "source": "champion-cross", + "query": "まんが", + "http_client": None, + "limit": 3, + }, + { + "source": "takecomic", + "query": "まんが", + "http_client": None, + "limit": 3, + }, ], - "takecomic": [ - SearchResult( - source="takecomic", - title="take only", - seed_url="https://takecomic.jp/comics/1", - ) + search_source.calls, + ) + test.assertEqual("横断検索結果です。1件選んでください。", response["content"]) + test.assertEqual( + [ + { + "label": "作品A walker 1", + "value": "https://comic-walker.com/detail/a", + "description": "comic-walker", + }, + { + "label": "作品B cross 1", + "value": "https://championcross.jp/series/a", + "description": "champion-cross", + }, + { + "label": "作品C take 1", + "value": "https://takecomic.jp/comics/1", + "description": "takecomic", + }, + { + "label": "作品A walker 2", + "value": "https://comic-walker.com/detail/b", + "description": "comic-walker", + }, + { + "label": "作品B cross 2", + "value": "https://championcross.jp/series/b", + "description": "champion-cross", + }, ], - }, - failing_sources={"champion-cross"}, - ) - handler = SearchCommandHandler( - search_source=search_source, - supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), - ) + response["components"][0]["components"][0]["options"], + ) - response = handler.start(source=None, query="まんが") - - self.assertEqual("横断検索結果です。1件選んでください。", response["content"]) - self.assertEqual( - [ + def run_select_limit_cap(test): + sources = tuple(f"source-{index}" for index in range(9)) + search_source = MultiSourceSearchSource( { - "label": "walker only", - "value": "https://comic-walker.com/detail/1", - "description": "comic-walker", + source_name: [ + SearchResult( + source=source_name, + title=f"{source_name}-{index}", + seed_url=f"https://example.com/{source_name}/{index}", + ) + for index in range(10) + ] + for source_name in sources + } + ) + handler = SearchCommandHandler( + search_source=search_source, + supported_sources=lambda: sources, + ) + + response = handler.start(source=None, query="まんが") + + options = response["components"][0]["components"][0]["options"] + test.assertEqual(25, len(options)) + test.assertEqual("source-0-0", options[0]["label"]) + test.assertEqual("source-1-0", options[1]["label"]) + test.assertEqual("source-2-0", options[2]["label"]) + + def run_partial_results_when_some_sources_fail(test): + search_source = MultiSourceSearchSourceWithFailures( + results_by_source={ + "comic-walker": [ + SearchResult( + source="comic-walker", + title="walker only", + seed_url="https://comic-walker.com/detail/1", + ) + ], + "takecomic": [ + SearchResult( + source="takecomic", + title="take only", + seed_url="https://takecomic.jp/comics/1", + ) + ], }, - { - "label": "take only", - "value": "https://takecomic.jp/comics/1", - "description": "takecomic", + failing_sources={"champion-cross"}, + ) + handler = SearchCommandHandler( + search_source=search_source, + supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), + ) + + response = handler.start(source=None, query="まんが") + + test.assertEqual("横断検索結果です。1件選んでください。", response["content"]) + test.assertEqual( + [ + { + "label": "walker only", + "value": "https://comic-walker.com/detail/1", + "description": "comic-walker", + }, + { + "label": "take only", + "value": "https://takecomic.jp/comics/1", + "description": "takecomic", + }, + ], + response["components"][0]["components"][0]["options"], + ) + + def run_failure_message_when_all_sources_fail(test): + search_source = MultiSourceSearchSourceWithFailures( + failing_sources={"comic-walker", "champion-cross", "takecomic"} + ) + handler = SearchCommandHandler( + search_source=search_source, + supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), + ) + + response = handler.start(source=None, query="まんが") + + test.assertEqual({"content": "作品検索に失敗しました。サーバーログを確認してください。", "components": []}, response) + + def run_no_results_when_failures_mix_with_empty_successes(test): + search_source = MultiSourceSearchSourceWithFailures( + results_by_source={ + "comic-walker": [], + "takecomic": [], }, - ], - response["components"][0]["components"][0]["options"], - ) + failing_sources={"champion-cross"}, + ) + handler = SearchCommandHandler( + search_source=search_source, + supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), + ) - def test_start_returns_failure_message_when_all_cross_source_searches_fail(self): - search_source = MultiSourceSearchSourceWithFailures( - failing_sources={"comic-walker", "champion-cross", "takecomic"} - ) - handler = SearchCommandHandler( - search_source=search_source, - supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), - ) + response = handler.start(source=None, query="まんが") - response = handler.start(source=None, query="まんが") + test.assertEqual({"content": SEARCH_NO_RESULTS_MESSAGE, "components": []}, response) - self.assertEqual({"content": "作品検索に失敗しました。サーバーログを確認してください。", "components": []}, response) - - def test_start_returns_no_results_when_failures_mix_with_empty_successes(self): - search_source = MultiSourceSearchSourceWithFailures( - results_by_source={ - "comic-walker": [], - "takecomic": [], + scenarios = [ + { + "scenario": "interleaving_and_dedupe", + "sources": ("comic-walker", "champion-cross", "takecomic"), + "limit": 3, + "run": run_interleaving_and_dedupe, }, - failing_sources={"champion-cross"}, - ) - handler = SearchCommandHandler( - search_source=search_source, - supported_sources=lambda: ("comic-walker", "champion-cross", "takecomic"), - ) - - response = handler.start(source=None, query="まんが") - - self.assertEqual({"content": SEARCH_NO_RESULTS_MESSAGE, "components": []}, response) + { + "scenario": "select_limit_cap", + "sources": tuple(f"source-{index}" for index in range(9)), + "limit": 3, + "run": run_select_limit_cap, + }, + { + "scenario": "partial_results_with_failures", + "sources": ("comic-walker", "champion-cross", "takecomic"), + "limit": 3, + "run": run_partial_results_when_some_sources_fail, + }, + { + "scenario": "all_sources_fail", + "sources": ("comic-walker", "champion-cross", "takecomic"), + "limit": 3, + "run": run_failure_message_when_all_sources_fail, + }, + { + "scenario": "failures_mix_with_empty_successes", + "sources": ("comic-walker", "champion-cross", "takecomic"), + "limit": 3, + "run": run_no_results_when_failures_mix_with_empty_successes, + }, + ] + for scenario in scenarios: + with self.subTest( + scenario=scenario["scenario"], + sources=scenario["sources"], + limit=scenario["limit"], + ): + scenario["run"](self) def test_handle_component_adds_selected_result_with_hidden_flag(self): add_subscription = FakeAddSubscription() diff --git a/tests/test_discord_supertwins_interactions.py b/tests/test_discord_supertwins_interactions.py index a5b6e4d..18ba2b9 100644 --- a/tests/test_discord_supertwins_interactions.py +++ b/tests/test_discord_supertwins_interactions.py @@ -9,7 +9,6 @@ DiscordRequestVerifier, ) from manga_watch.discord_supertwins_manage import SUPERTWINS_MANAGE_COMMAND -from manga_watch.discord_supertwins_search import SUPERTWINS_SEARCH_COMMAND class RecordingFetchDispatcher: @@ -73,30 +72,6 @@ def signed_request(self, payload, signing_key): } return headers, body - def test_supertwins_search_command_routes_to_ephemeral_handler(self): - class FakeSearchHandler: - def __init__(self): - self.calls = [] - - def start(self, **kwargs): - self.calls.append(kwargs) - return {"content": "候補検索はまだ有効化されていません。", "components": []} - - handler = FakeSearchHandler() - service, signing_key = self.make_service(supertwins_search_handler=handler) - headers, body = self.signed_request( - {"type": 2, "data": {"name": SUPERTWINS_SEARCH_COMMAND}}, - signing_key, - ) - - response = service.handle_request(method="POST", path="/", headers=headers, body=body) - - payload = json.loads(response.body) - self.assertEqual(4, payload["type"]) - self.assertEqual(64, payload["data"]["flags"]) - self.assertEqual("候補検索はまだ有効化されていません。", payload["data"]["content"]) - self.assertEqual(1, len(handler.calls)) - def test_supertwins_manage_command_routes_to_ephemeral_handler(self): class FakeManageHandler: def __init__(self): @@ -121,29 +96,6 @@ def start(self, **kwargs): self.assertEqual("supertwins 管理はまだ有効化されていません。", payload["data"]["content"]) self.assertEqual(1, len(handler.calls)) - def test_supertwins_search_component_prefix_routes_to_handler(self): - class FakeSearchHandler: - def __init__(self): - self.calls = [] - - def handle_component(self, data, **kwargs): - self.calls.append({"data": data, **kwargs}) - return {"content": "updated-search", "components": []} - - handler = FakeSearchHandler() - service, signing_key = self.make_service(supertwins_search_handler=handler) - headers, body = self.signed_request( - {"type": 3, "data": {"custom_id": "supertwins_search:placeholder"}}, - signing_key, - ) - - response = service.handle_request(method="POST", path="/", headers=headers, body=body) - - payload = json.loads(response.body) - self.assertEqual(7, payload["type"]) - self.assertEqual("updated-search", payload["data"]["content"]) - self.assertEqual("supertwins_search:placeholder", handler.calls[0]["data"]["custom_id"]) - def test_supertwins_search_root_select_defers_and_edits_original_message(self): class FakeSearchHandler: def __init__(self): @@ -203,66 +155,6 @@ def handle_component(self, data, **kwargs): ) self.assertEqual("supertwins_search_work_select", handler.calls[0]["data"]["custom_id"]) - def test_supertwins_search_result_select_still_uses_inline_update_message(self): - class FakeSearchHandler: - def __init__(self): - self.calls = [] - - def handle_component(self, data, **kwargs): - self.calls.append({"data": data, **kwargs}) - return {"content": "updated-search", "components": []} - - callback_client = RecordingInteractionCallbackClient() - handler = FakeSearchHandler() - service, signing_key = self.make_service( - supertwins_search_handler=handler, - interaction_callback_client=callback_client, - ) - headers, body = self.signed_request( - { - "id": "interaction-1", - "application_id": "app-1", - "token": "token-1", - "type": 3, - "data": { - "custom_id": "supertwins_search:results:token-1", - "values": ["candidate-1"], - }, - }, - signing_key, - ) - - response = service.handle_request(method="POST", path="/", headers=headers, body=body) - - payload = json.loads(response.body) - self.assertEqual(7, payload["type"]) - self.assertEqual("updated-search", payload["data"]["content"]) - self.assertEqual([], callback_client.defer_calls) - self.assertEqual([], callback_client.edit_calls) - - def test_supertwins_manage_component_prefix_routes_to_handler(self): - class FakeManageHandler: - def __init__(self): - self.calls = [] - - def handle_component(self, data, **kwargs): - self.calls.append({"data": data, **kwargs}) - return {"content": "updated-manage", "components": []} - - handler = FakeManageHandler() - service, signing_key = self.make_service(supertwins_manage_handler=handler) - headers, body = self.signed_request( - {"type": 3, "data": {"custom_id": "supertwins_manage:placeholder"}}, - signing_key, - ) - - response = service.handle_request(method="POST", path="/", headers=headers, body=body) - - payload = json.loads(response.body) - self.assertEqual(7, payload["type"]) - self.assertEqual("updated-manage", payload["data"]["content"]) - self.assertEqual("supertwins_manage:placeholder", handler.calls[0]["data"]["custom_id"]) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_discord_title_search.py b/tests/test_discord_title_search.py index 9d9d9ad..535505f 100644 --- a/tests/test_discord_title_search.py +++ b/tests/test_discord_title_search.py @@ -1,6 +1,6 @@ import unittest -from manga_watch.discord_title_search import TITLE_COMMAND, TITLE_USAGE_MESSAGE, handle_title_query +from manga_watch.discord_title_search import TITLE_USAGE_MESSAGE, handle_title_query class RecordingSearchSource: @@ -38,9 +38,6 @@ def __call__(self, source, query, *, limit=10, http_client=None): class DiscordTitleSearchTests(unittest.TestCase): - def test_title_command_name_is_exported(self): - self.assertEqual("title", TITLE_COMMAND) - def test_handle_title_query_returns_none_for_non_title_messages(self): self.assertIsNone(handle_title_query("latest")) self.assertIsNone(handle_title_query("titleworks")) diff --git a/tests/test_discord_where.py b/tests/test_discord_where.py index 3a4af40..a2983ee 100644 --- a/tests/test_discord_where.py +++ b/tests/test_discord_where.py @@ -4,7 +4,6 @@ from manga_watch.discord_where import ( StoredWhereContextStore, - WHERE_COMMAND, WHERE_NO_RESULTS_MESSAGE, WhereCommandHandler, ) @@ -88,9 +87,6 @@ def __call__(self, source, seed_url, episode, *, http_client=None): class DiscordWhereTests(unittest.TestCase): - def test_where_command_name_is_exported(self): - self.assertEqual("where", WHERE_COMMAND) - def test_start_searches_availability_sources_and_returns_select_menu(self): search_source = MultiSourceSearchSource( { @@ -226,131 +222,141 @@ def test_handle_component_uses_selected_candidate_when_same_source_titles_collid self.assertEqual("https://comic-walker.com/detail/selected", resolver.calls[0]["seed_url"]) self.assertEqual("https://manga.nicovideo.jp/comic/selected", resolver.calls[1]["seed_url"]) - def test_handle_component_loads_context_from_storage_across_handler_instances(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="ニセモノの錬金術師", - seed_url="https://comic-walker.com/detail/KC_004800_S", - ) - ], - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - } - ) - resolver = FakeAvailabilityResolver() - with tempfile.TemporaryDirectory() as tmpdir: - state_path = str(Path(tmpdir) / "state.json") - first_handler = WhereCommandHandler( - search_source=search_source, - context_store=StoredWhereContextStore(state_path=state_path, backend="json"), - ) - start_response = first_handler.start(query="ニセモノの錬金術師", episode="1話") - select = start_response["components"][0]["components"][0] - second_handler = WhereCommandHandler( - availability_resolver=resolver, - context_store=StoredWhereContextStore(state_path=state_path, backend="json"), + def test_where_context_store_lifecycle_matrix(self): + def run_loads_context_across_handler_instances(test): + search_source = MultiSourceSearchSource( + { + "comic-walker": [ + SearchResult( + source="comic-walker", + title="ニセモノの錬金術師", + seed_url="https://comic-walker.com/detail/KC_004800_S", + ) + ], + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], + } ) - - response = second_handler.handle_component( - {"custom_id": select["custom_id"], "values": ["0"]}, + resolver = FakeAvailabilityResolver() + with tempfile.TemporaryDirectory() as tmpdir: + state_path = str(Path(tmpdir) / "state.json") + first_handler = WhereCommandHandler( + search_source=search_source, + context_store=StoredWhereContextStore(state_path=state_path, backend="json"), + ) + start_response = first_handler.start(query="ニセモノの錬金術師", episode="1話") + select = start_response["components"][0]["components"][0] + second_handler = WhereCommandHandler( + availability_resolver=resolver, + context_store=StoredWhereContextStore(state_path=state_path, backend="json"), + ) + + response = second_handler.handle_component( + {"custom_id": select["custom_id"], "values": ["0"]}, + ) + + test.assertIn("ComicWalker: 今すぐ無料", response["content"]) + test.assertIn("ニコニコ漫画: 今すぐ無料", response["content"]) + test.assertEqual( + [ + { + "source": "comic-walker", + "seed_url": "https://comic-walker.com/detail/KC_004800_S", + "episode": "1話", + "http_client": None, + }, + { + "source": "nicovideo-manga", + "seed_url": "https://manga.nicovideo.jp/comic/62782", + "episode": "1話", + "http_client": None, + }, + ], + resolver.calls, ) - self.assertIn("ComicWalker: 今すぐ無料", response["content"]) - self.assertIn("ニコニコ漫画: 今すぐ無料", response["content"]) - self.assertEqual( - [ + def run_deletes_storage_context_after_selection(test): + search_source = MultiSourceSearchSource( { - "source": "comic-walker", - "seed_url": "https://comic-walker.com/detail/KC_004800_S", - "episode": "1話", - "http_client": None, - }, + "comic-walker": [ + SearchResult( + source="comic-walker", + title="ニセモノの錬金術師", + seed_url="https://comic-walker.com/detail/KC_004800_S", + ) + ], + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], + } + ) + resolver = FakeAvailabilityResolver() + with tempfile.TemporaryDirectory() as tmpdir: + state_path = str(Path(tmpdir) / "state.json") + handler = WhereCommandHandler( + search_source=search_source, + availability_resolver=resolver, + context_store=StoredWhereContextStore(state_path=state_path, backend="json"), + ) + start_response = handler.start(query="ニセモノの錬金術師", episode="1話") + select = start_response["components"][0]["components"][0] + + first_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + second_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + + test.assertIn("ComicWalker: 今すぐ無料", first_response["content"]) + test.assertIn("有効期限が切れた", second_response["content"]) + + def run_unique_context_token_for_identical_searches(test): + search_source = MultiSourceSearchSource( { - "source": "nicovideo-manga", - "seed_url": "https://manga.nicovideo.jp/comic/62782", - "episode": "1話", - "http_client": None, - }, - ], - resolver.calls, - ) - - def test_handle_component_deletes_storage_context_after_selection(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="ニセモノの錬金術師", - seed_url="https://comic-walker.com/detail/KC_004800_S", - ) - ], - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - } - ) - resolver = FakeAvailabilityResolver() - with tempfile.TemporaryDirectory() as tmpdir: - state_path = str(Path(tmpdir) / "state.json") - handler = WhereCommandHandler( - search_source=search_source, - availability_resolver=resolver, - context_store=StoredWhereContextStore(state_path=state_path, backend="json"), + "comic-walker": [ + SearchResult( + source="comic-walker", + title="ニセモノの錬金術師", + seed_url="https://comic-walker.com/detail/KC_004800_S", + ) + ], + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], + } ) - start_response = handler.start(query="ニセモノの錬金術師", episode="1話") - select = start_response["components"][0]["components"][0] - - first_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) - second_response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) - - self.assertIn("ComicWalker: 今すぐ無料", first_response["content"]) - self.assertIn("有効期限が切れた", second_response["content"]) + handler = WhereCommandHandler(search_source=search_source, availability_resolver=FakeAvailabilityResolver()) + first_response = handler.start(query="ニセモノの錬金術師", episode="1話") + second_response = handler.start(query="ニセモノの錬金術師", episode="1話") + first_select = first_response["components"][0]["components"][0] + second_select = second_response["components"][0]["components"][0] - def test_start_generates_unique_context_token_for_identical_searches(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="ニセモノの錬金術師", - seed_url="https://comic-walker.com/detail/KC_004800_S", - ) - ], - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - } - ) - handler = WhereCommandHandler(search_source=search_source, availability_resolver=FakeAvailabilityResolver()) - first_response = handler.start(query="ニセモノの錬金術師", episode="1話") - second_response = handler.start(query="ニセモノの錬金術師", episode="1話") - first_select = first_response["components"][0]["components"][0] - second_select = second_response["components"][0]["components"][0] + test.assertNotEqual(first_select["custom_id"], second_select["custom_id"]) - self.assertNotEqual(first_select["custom_id"], second_select["custom_id"]) + first_result = handler.handle_component({"custom_id": first_select["custom_id"], "values": ["0"]}) + second_result = handler.handle_component({"custom_id": second_select["custom_id"], "values": ["0"]}) - first_result = handler.handle_component({"custom_id": first_select["custom_id"], "values": ["0"]}) - second_result = handler.handle_component({"custom_id": second_select["custom_id"], "values": ["0"]}) + test.assertIn("ComicWalker: 今すぐ無料", first_result["content"]) + test.assertIn("ComicWalker: 今すぐ無料", second_result["content"]) - self.assertIn("ComicWalker: 今すぐ無料", first_result["content"]) - self.assertIn("ComicWalker: 今すぐ無料", second_result["content"]) + cases = [ + ("loads_context_across_handler_instances", run_loads_context_across_handler_instances), + ("deletes_storage_context_after_selection", run_deletes_storage_context_after_selection), + ("unique_context_token_for_identical_searches", run_unique_context_token_for_identical_searches), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) def test_start_returns_no_results_when_supported_sources_have_no_candidates(self): handler = WhereCommandHandler(search_source=MultiSourceSearchSource({})) @@ -359,111 +365,121 @@ def test_start_returns_no_results_when_supported_sources_have_no_candidates(self self.assertEqual({"content": WHERE_NO_RESULTS_MESSAGE, "components": []}, response) - def test_handle_component_renders_not_found_when_episode_is_missing(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="ニセモノの錬金術師", - seed_url="https://comic-walker.com/detail/KC_004800_S", - ) - ], - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - } - ) - resolver = StaticAvailabilityResolver( - { - "comic-walker": {"source": "comic-walker", "status": "not_found", "url": None}, - "nicovideo-manga": {"source": "nicovideo-manga", "status": "not_found", "url": None}, - } - ) - handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) - start_response = handler.start(query="ニセモノの錬金術師", episode="第99話") - select = start_response["components"][0]["components"][0] + def test_handle_component_availability_render_matrix(self): + def run_not_found_when_episode_is_missing(test): + search_source = MultiSourceSearchSource( + { + "comic-walker": [ + SearchResult( + source="comic-walker", + title="ニセモノの錬金術師", + seed_url="https://comic-walker.com/detail/KC_004800_S", + ) + ], + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], + } + ) + resolver = StaticAvailabilityResolver( + { + "comic-walker": {"source": "comic-walker", "status": "not_found", "url": None}, + "nicovideo-manga": {"source": "nicovideo-manga", "status": "not_found", "url": None}, + } + ) + handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) + start_response = handler.start(query="ニセモノの錬金術師", episode="第99話") + select = start_response["components"][0]["components"][0] - response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) - self.assertIn("ComicWalker: 見つからない", response["content"]) - self.assertIn("ニコニコ漫画: 見つからない", response["content"]) + test.assertIn("ComicWalker: 見つからない", response["content"]) + test.assertIn("ニコニコ漫画: 見つからない", response["content"]) - def test_handle_component_renders_needs_check_for_failed_source_search(self): - search_source = FailingSourceSearchSource( - { - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - }, - failing_sources={"comic-walker"}, - ) - resolver = FakeAvailabilityResolver() - handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) - start_response = handler.start(query="ニセモノの錬金術師", episode="1話") - select = start_response["components"][0]["components"][0] + def run_needs_check_for_failed_source_search(test): + search_source = FailingSourceSearchSource( + { + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], + }, + failing_sources={"comic-walker"}, + ) + resolver = FakeAvailabilityResolver() + handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) + start_response = handler.start(query="ニセモノの錬金術師", episode="1話") + select = start_response["components"][0]["components"][0] - response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + + test.assertIn("ComicWalker: 要確認", response["content"]) + test.assertIn("ニコニコ漫画: 今すぐ無料", response["content"]) + test.assertEqual( + [ + { + "source": "nicovideo-manga", + "seed_url": "https://manga.nicovideo.jp/comic/62782", + "episode": "1話", + "http_client": None, + } + ], + resolver.calls, + ) - self.assertIn("ComicWalker: 要確認", response["content"]) - self.assertIn("ニコニコ漫画: 今すぐ無料", response["content"]) - self.assertEqual( - [ + def run_needs_check_with_seed_url_when_resolution_fails(test): + search_source = MultiSourceSearchSource( { - "source": "nicovideo-manga", - "seed_url": "https://manga.nicovideo.jp/comic/62782", - "episode": "1話", - "http_client": None, + "comic-walker": [ + SearchResult( + source="comic-walker", + title="ニセモノの錬金術師", + seed_url="https://comic-walker.com/detail/KC_004800_S", + ) + ], + "nicovideo-manga": [ + SearchResult( + source="nicovideo-manga", + title="ニセモノの錬金術師", + seed_url="https://manga.nicovideo.jp/comic/62782", + ) + ], } - ], - resolver.calls, - ) + ) + resolver = StaticAvailabilityResolver( + { + "comic-walker": RuntimeError("temporary fetch failure"), + "nicovideo-manga": { + "source": "nicovideo-manga", + "status": "free_now", + "url": "https://manga.nicovideo.jp/watch/mg1000001", + }, + } + ) + handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) + start_response = handler.start(query="ニセモノの錬金術師", episode="第1話") + select = start_response["components"][0]["components"][0] - def test_handle_component_renders_needs_check_with_seed_url_when_resolution_fails(self): - search_source = MultiSourceSearchSource( - { - "comic-walker": [ - SearchResult( - source="comic-walker", - title="ニセモノの錬金術師", - seed_url="https://comic-walker.com/detail/KC_004800_S", - ) - ], - "nicovideo-manga": [ - SearchResult( - source="nicovideo-manga", - title="ニセモノの錬金術師", - seed_url="https://manga.nicovideo.jp/comic/62782", - ) - ], - } - ) - resolver = StaticAvailabilityResolver( - { - "comic-walker": RuntimeError("temporary fetch failure"), - "nicovideo-manga": { - "source": "nicovideo-manga", - "status": "free_now", - "url": "https://manga.nicovideo.jp/watch/mg1000001", - }, - } - ) - handler = WhereCommandHandler(search_source=search_source, availability_resolver=resolver) - start_response = handler.start(query="ニセモノの錬金術師", episode="第1話") - select = start_response["components"][0]["components"][0] + response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) - response = handler.handle_component({"custom_id": select["custom_id"], "values": ["0"]}) + test.assertIn("ComicWalker: 要確認", response["content"]) + test.assertIn("https://comic-walker.com/detail/KC_004800_S", response["content"]) - self.assertIn("ComicWalker: 要確認", response["content"]) - self.assertIn("https://comic-walker.com/detail/KC_004800_S", response["content"]) + cases = [ + ("not_found", run_not_found_when_episode_is_missing), + ("failed_source_search", run_needs_check_for_failed_source_search), + ("resolution_failure", run_needs_check_with_seed_url_when_resolution_fails), + ] + for case, run in cases: + with self.subTest(case=case): + run(self) if __name__ == "__main__": diff --git a/tests/test_firestore_storage.py b/tests/test_firestore_storage.py index a4a23e9..7ff343e 100644 --- a/tests/test_firestore_storage.py +++ b/tests/test_firestore_storage.py @@ -1,4 +1,3 @@ -import json import os import unittest from unittest import mock @@ -19,154 +18,7 @@ validate_state, validate_watchlist, ) - - -def make_watchlist(): - return { - "version": 2, - "works": [ - { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://comic-walker.com/detail/work-1", - "enabled": True, - "notification_policy": { - "mode": "all", - "allowed_update_types": None, - }, - } - ], - } - - -def make_state(): - return { - "version": 2, - "works": { - "work-1": { - "latest": { - "series_title": "作品A", - "episode_title": "第2話", - "latest_key": "episode-2", - "url": "https://example.com/episodes/2", - "update_type": "main_story", - "default_notify": True, - }, - "history": [ - { - "event_id": "event-1", - "detected_at": "2023-11-14T22:13:20Z", - "latest": { - "series_title": "作品A", - "episode_title": "第2話", - "latest_key": "episode-2", - }, - } - ], - "unread": {"event_ids": ["event-1"]}, - } - }, - "last_run_at": 1_700_000_000, - "notification_outbox": [ - { - "event": { - "schema_version": 1, - "event_id": "event-1", - "work_id": "work-1", - "latest_key": "episode-2", - "series_title": "作品A", - "update_type": "main_story", - "detected_at": "2023-11-14T22:13:20Z", - "from": {"latest_key": "episode-1"}, - "to": {"latest_key": "episode-2"}, - }, - "pending_backends": ["stdout", "webhook"], - "attempt_count": 1, - "last_attempted_at": "2023-11-14T22:15:00Z", - "last_error": "webhook timeout", - } - ], - "discord_delivery": { - "daily_notification": { - "delivered_latest_keys": { - "work-1": { - "latest_key": "episode-2", - "delivered_at": None, - }, - }, - "pending_messages": [ - { - "channel_id": "main-channel", - "content": "pending daily message", - "message_keys": [ - { - "work_id": "work-1", - "latest_key": "episode-2", - } - ], - "created_at": "2023-11-14T22:13:20Z", - "attempt_count": 0, - "last_attempted_at": None, - "last_error": None, - } - ], - } - }, - } - - -class FakeSnapshot: - def __init__(self, doc_id, payload): - self.id = doc_id - self._payload = None if payload is None else json.loads(json.dumps(payload, ensure_ascii=False)) - - @property - def exists(self): - return self._payload is not None - - def to_dict(self): - if self._payload is None: - return None - return json.loads(json.dumps(self._payload, ensure_ascii=False)) - - -class FakeDocument: - def __init__(self, store, collection_name, doc_id): - self.store = store - self.collection_name = collection_name - self.doc_id = doc_id - - def get(self): - return FakeSnapshot(self.doc_id, self.store.get(self.collection_name, {}).get(self.doc_id)) - - def set(self, payload): - self.store.setdefault(self.collection_name, {})[self.doc_id] = json.loads( - json.dumps(payload, ensure_ascii=False) - ) - - def delete(self): - self.store.setdefault(self.collection_name, {}).pop(self.doc_id, None) - - -class FakeCollection: - def __init__(self, store, collection_name): - self.store = store - self.collection_name = collection_name - - def document(self, doc_id): - return FakeDocument(self.store, self.collection_name, doc_id) - - def stream(self): - docs = self.store.setdefault(self.collection_name, {}) - return [FakeSnapshot(doc_id, payload) for doc_id, payload in sorted(docs.items())] - - -class FakeFirestoreClient: - def __init__(self): - self.store = {} - - def collection(self, collection_name): - return FakeCollection(self.store, collection_name) +from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist class FirestoreStorageTests(unittest.TestCase): @@ -250,48 +102,39 @@ def test_env_selected_firestore_backend_is_used_without_explicit_backend_arg(sel self.assertEqual("work-1", loaded["works"][0]["id"]) - def test_firestore_backend_round_trips_supertwins_search_sessions_by_token(self): - repository = self.make_repository() - - with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository): - save_supertwins_search_session( - "session-a", - {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}}, - backend="firestore", - ) - save_supertwins_search_session( - "session-b", - {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}}, - backend="firestore", - ) - session_a = load_supertwins_search_session("session-a", backend="firestore") - session_b = load_supertwins_search_session("session-b", backend="firestore") - delete_supertwins_search_session("session-a", backend="firestore") - - self.assertEqual("root-1", session_a["root_work_id"]) - self.assertEqual("root-2", session_b["root_work_id"]) - self.assertIn("runtime:session-b", repository.client.store["supertwins_search_sessions"]) - self.assertNotIn("runtime:session-a", repository.client.store["supertwins_search_sessions"]) - - def test_firestore_backend_round_trips_where_sessions_by_token(self): - repository = self.make_repository() - - with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository): - save_where_session( - "session-a", - {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]}, - backend="firestore", - ) - save_where_session( - "session-b", - {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]}, - backend="firestore", - ) - session_a = load_where_session("session-a", backend="firestore") - session_b = load_where_session("session-b", backend="firestore") - delete_where_session("session-a", backend="firestore") - - self.assertEqual("作品A", session_a["query"]) - self.assertEqual("作品B", session_b["query"]) - self.assertIn("runtime:session-b", repository.client.store["where_sessions"]) - self.assertNotIn("runtime:session-a", repository.client.store["where_sessions"]) + def test_firestore_backend_round_trips_search_sessions_by_token(self): + session_cases = { + "supertwins": { + "save": save_supertwins_search_session, + "load": load_supertwins_search_session, + "delete": delete_supertwins_search_session, + "payload_a": {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}}, + "payload_b": {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}}, + "collection": "supertwins_search_sessions", + "field": "root_work_id", + }, + "where": { + "save": save_where_session, + "load": load_where_session, + "delete": delete_where_session, + "payload_a": {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]}, + "payload_b": {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]}, + "collection": "where_sessions", + "field": "query", + }, + } + for session_kind, funcs in session_cases.items(): + with self.subTest(session_kind=session_kind): + repository = self.make_repository() + + with mock.patch("manga_watch.storage.get_firestore_repository", return_value=repository): + funcs["save"]("session-a", funcs["payload_a"], backend="firestore") + funcs["save"]("session-b", funcs["payload_b"], backend="firestore") + session_a = funcs["load"]("session-a", backend="firestore") + session_b = funcs["load"]("session-b", backend="firestore") + funcs["delete"]("session-a", backend="firestore") + + self.assertEqual(funcs["payload_a"][funcs["field"]], session_a[funcs["field"]]) + self.assertEqual(funcs["payload_b"][funcs["field"]], session_b[funcs["field"]]) + self.assertIn("runtime:session-b", repository.client.store[funcs["collection"]]) + self.assertNotIn("runtime:session-a", repository.client.store[funcs["collection"]]) diff --git a/tests/test_github_issue_reporting.py b/tests/test_github_issue_reporting.py index a1a33b3..1d971f7 100644 --- a/tests/test_github_issue_reporting.py +++ b/tests/test_github_issue_reporting.py @@ -36,41 +36,20 @@ def post(self, url, **kwargs): class GitHubIssueReportingTests(unittest.TestCase): - def test_build_reporter_from_env_returns_none_when_unconfigured(self): + def test_build_reporter_from_env_returns_none_when_incomplete(self): from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env - with mock.patch.dict(os.environ, {}, clear=True): - reporter = build_unsupported_source_issue_reporter_from_env() - - self.assertIsNone(reporter) - - def test_build_reporter_from_env_returns_none_when_only_token_is_present(self): - from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env - - with mock.patch.dict( - os.environ, - { - "MANGA_WATCH_GITHUB_TOKEN": "github-token", - }, - clear=True, - ): - reporter = build_unsupported_source_issue_reporter_from_env() + cases = { + "neither_present": {}, + "only_token": {"MANGA_WATCH_GITHUB_TOKEN": "github-token"}, + "only_repository": {"MANGA_WATCH_GITHUB_REPOSITORY": "kentoku24/comic_crawler"}, + } + for case, env in cases.items(): + with self.subTest(case=case): + with mock.patch.dict(os.environ, env, clear=True): + reporter = build_unsupported_source_issue_reporter_from_env() - self.assertIsNone(reporter) - - def test_build_reporter_from_env_returns_none_when_only_repository_is_present(self): - from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env - - with mock.patch.dict( - os.environ, - { - "MANGA_WATCH_GITHUB_REPOSITORY": "kentoku24/comic_crawler", - }, - clear=True, - ): - reporter = build_unsupported_source_issue_reporter_from_env() - - self.assertIsNone(reporter) + self.assertIsNone(reporter) def test_report_unsupported_source_creates_issue(self): from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig @@ -156,89 +135,52 @@ def test_report_unsupported_source_redacts_query_and_userinfo_from_issue_body(se def test_report_unsupported_source_reuses_existing_open_issue(self): from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig - session = FakeRequestsSession( - get_responses=[ - FakeResponse( - status_code=200, - json_data=[ - { - "number": 175, - "html_url": "https://github.com/kentoku24/comic_crawler/issues/175", - "title": "Unsupported source request from Discord /add: example.com", - "body": ( - "\n" - "- Input URL: `https://example.com/work/1`\n" - "- Host: `example.com`\n" - ), - } + cases = { + "exact_url": "https://example.com/work/1", + "host_with_port_and_query": "https://example.com:443/work/1?token=secret", + } + for case, url in cases.items(): + with self.subTest(case=case): + session = FakeRequestsSession( + get_responses=[ + FakeResponse( + status_code=200, + json_data=[ + { + "number": 175, + "html_url": "https://github.com/kentoku24/comic_crawler/issues/175", + "title": "Unsupported source request from Discord /add: example.com", + "body": ( + "\n" + "- Input URL: `https://example.com/work/1`\n" + "- Host: `example.com`\n" + ), + } + ], + ) ], ) - ], - ) - reporter = GitHubIssueReporter( - GitHubIssueReporterConfig( - token="github-token", - repository="kentoku24/comic_crawler", - ), - session=session, - ) - - outcome = reporter.report_unsupported_source( - url="https://example.com/work/1", - error=WatchlistAddError( - "unsupported_source", - "Unsupported source host: example.com", - "Use one of the supported sources.", - ), - ) - - self.assertEqual("duplicate", outcome["action"]) - self.assertEqual(175, outcome["issue_number"]) - self.assertEqual(1, len(session.get_calls)) - self.assertEqual(0, len(session.post_calls)) - - def test_report_unsupported_source_deduplicates_by_hostname_without_port(self): - from manga_watch.github_issue_reporting import GitHubIssueReporter, GitHubIssueReporterConfig - - session = FakeRequestsSession( - get_responses=[ - FakeResponse( - status_code=200, - json_data=[ - { - "number": 175, - "html_url": "https://github.com/kentoku24/comic_crawler/issues/175", - "title": "Unsupported source request from Discord /add: example.com", - "body": ( - "\n" - "- Input URL: `https://example.com/work/1`\n" - "- Host: `example.com`\n" - ), - } - ], + reporter = GitHubIssueReporter( + GitHubIssueReporterConfig( + token="github-token", + repository="kentoku24/comic_crawler", + ), + session=session, ) - ], - ) - reporter = GitHubIssueReporter( - GitHubIssueReporterConfig( - token="github-token", - repository="kentoku24/comic_crawler", - ), - session=session, - ) - outcome = reporter.report_unsupported_source( - url="https://example.com:443/work/1?token=secret", - error=WatchlistAddError( - "unsupported_source", - "Unsupported source host: example.com", - "Use one of the supported sources.", - ), - ) + outcome = reporter.report_unsupported_source( + url=url, + error=WatchlistAddError( + "unsupported_source", + "Unsupported source host: example.com", + "Use one of the supported sources.", + ), + ) - self.assertEqual("duplicate", outcome["action"]) - self.assertEqual(175, outcome["issue_number"]) - self.assertEqual(0, len(session.post_calls)) + self.assertEqual("duplicate", outcome["action"]) + self.assertEqual(175, outcome["issue_number"]) + self.assertEqual(1, len(session.get_calls)) + self.assertEqual(0, len(session.post_calls)) def test_build_reporter_from_env_uses_namespaced_token_and_repository(self): from manga_watch.github_issue_reporting import build_unsupported_source_issue_reporter_from_env diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 7d746dd..982158c 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -13,87 +13,290 @@ def read_workflow(name: str) -> str: return workflow_path.read_text(encoding="utf-8") +def _strip_quotes(value: str) -> str: + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + return value[1:-1] + return value + + +def _is_blank_or_comment(line: str) -> bool: + stripped = line.strip() + return stripped == "" or stripped == "---" or stripped.startswith("#") + + +def _indent_of(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def _split_key_value(text: str): + """Split 'key: value' on the first colon that ends the key. + + Returns (key, value) or None when the colon is not a mapping colon + (e.g. 'https://...' inside a scalar). + """ + head, sep, tail = text.partition(":") + if not sep: + return None + if tail != "" and not tail.startswith(" "): + return None + return head.strip(), tail.strip() + + +def _mini_yaml_load(text: str): + """Minimal stdlib YAML-subset parser for the production workflows. + + Handles nested mappings, '- ' sequences (incl. '- key: value' maps), + 'key: |' literal blocks and quoted scalars. Anything else (anchors, + flow collections, multi-line quotes) is out of scope on purpose: + the workflows under test do not use them. + """ + + lines = text.splitlines() + total = len(lines) + + def parse_node(i: int, indent: int): + while i < total and _is_blank_or_comment(lines[i]): + i += 1 + if i >= total or _indent_of(lines[i]) < indent: + return None, i + stripped = lines[i].lstrip(" ") + if stripped.startswith("- ") or stripped == "-": + return parse_list(i, indent) + return parse_map(i, indent) + + def parse_map(i: int, indent: int): + mapping = {} + while True: + while i < total and _is_blank_or_comment(lines[i]): + i += 1 + if i >= total or _indent_of(lines[i]) != indent: + break + stripped = lines[i].lstrip(" ") + if stripped.startswith("- ") or stripped == "-": + break + split = _split_key_value(stripped) + if split is None: + i += 1 + continue + key, rest = split + key = _strip_quotes(key) + i += 1 + if rest == "|": + block = [] + while i < total and ( + lines[i].strip() == "" or _indent_of(lines[i]) > indent + ): + if lines[i].strip() != "": + block.append(lines[i]) + i += 1 + if block: + dedent = min(_indent_of(line) for line in block) + block = [line[dedent:] for line in block] + mapping[key] = "\n".join(block) + ("\n" if block else "") + elif rest == "": + probe = i + while probe < total and _is_blank_or_comment(lines[probe]): + probe += 1 + if probe < total and _indent_of(lines[probe]) > indent: + child, i = parse_node(probe, _indent_of(lines[probe])) + mapping[key] = child + else: + mapping[key] = None + else: + mapping[key] = _strip_quotes(rest) + return mapping, i + + def parse_list(i: int, indent: int): + items = [] + while True: + while i < total and _is_blank_or_comment(lines[i]): + i += 1 + if i >= total or _indent_of(lines[i]) != indent: + break + stripped = lines[i].lstrip(" ") + if not (stripped.startswith("- ") or stripped == "-"): + break + after = stripped[1:].strip() + i += 1 + if after == "": + probe = i + while probe < total and _is_blank_or_comment(lines[probe]): + probe += 1 + if probe < total and _indent_of(lines[probe]) > indent: + child, i = parse_node(probe, _indent_of(lines[probe])) + items.append(child) + else: + items.append(None) + continue + split = _split_key_value(after) + if split is None: + items.append(_strip_quotes(after)) + continue + key, rest = split + item = {_strip_quotes(key): _strip_quotes(rest)} + probe = i + while probe < total and _is_blank_or_comment(lines[probe]): + probe += 1 + if probe < total and _indent_of(lines[probe]) > indent: + extra, i = parse_map(probe, _indent_of(lines[probe])) + item.update(extra) + items.append(item) + return items, i + + doc, _ = parse_node(0, 0) + return doc + + +def load_workflow(name: str): + """Parse a workflow file, preferring yaml.safe_load. + + PyYAML is not in requirements.txt (and therefore not in CI), so fall + back to the stdlib subset parser instead of adding a dependency. + """ + text = read_workflow(name) + try: + import yaml + except ImportError: + doc = _mini_yaml_load(text) + else: + doc = yaml.safe_load(text) + if isinstance(doc, dict) and "on" not in doc and True in doc: + # YAML 1.1 parses the 'on:' key as boolean True. + doc["on"] = doc.pop(True) + return doc + + +def job_steps(job: dict) -> list: + steps = job.get("steps") or [] + if not isinstance(steps, list): + raise AssertionError(f"job steps must be a list, got: {steps!r}") + return steps + + +def step_run_texts(steps: list) -> list: + return [step.get("run") or "" for step in steps if isinstance(step, dict)] + + class GitHubWorkflowContractTests(unittest.TestCase): def test_deploy_workflow_exists_and_targets_main_push(self): - content = read_workflow("deploy-production.yml") + doc = load_workflow("deploy-production.yml") - self.assertIn("on:", content) - self.assertIn("push:", content) - self.assertIn("branches:", content) - self.assertIn("- main", content) + branches = doc["on"]["push"]["branches"] + self.assertIn("main", branches) def test_deploy_workflow_defines_test_build_and_deploy_jobs(self): - content = read_workflow("deploy-production.yml") + doc = load_workflow("deploy-production.yml") - self.assertIn("jobs:", content) - self.assertIn("test:", content) - self.assertIn("build:", content) - self.assertIn("deploy:", content) - self.assertIn("environment: production", content) + jobs = doc["jobs"] + for expected in ("test", "build", "deploy"): + self.assertIn(expected, jobs) + self.assertEqual("production", jobs["deploy"].get("environment")) def test_deploy_workflow_emits_and_consumes_an_image_digest(self): - content = read_workflow("deploy-production.yml") - service_section = content.split(" - name: Deploy Cloud Run Service", maxsplit=1)[1] + doc = load_workflow("deploy-production.yml") - self.assertIn("image_digest", content) - self.assertIn("gcloud run jobs update comic-crawler-job", content) - self.assertIn("gcloud run deploy comic-crawler-service", content) - self.assertIn("DISCORD_BOT_TOKEN_SECRET_VERSION=${DISCORD_BOT_TOKEN_SECRET_VERSION}", service_section) - self.assertIn('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]', content) - self.assertIn('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]', content) + build_outputs = doc["jobs"]["build"].get("outputs") or {} + self.assertIn("image_digest", build_outputs) + self.assertIn("image_ref", build_outputs) + + deploy_env = doc["jobs"]["deploy"].get("env") or {} + self.assertIn("IMAGE_REF", deploy_env) + self.assertIn("IMAGE_DIGEST", deploy_env) + self.assertIn("needs.build.outputs.image_ref", str(deploy_env["IMAGE_REF"])) + self.assertIn( + "needs.build.outputs.image_digest", str(deploy_env["IMAGE_DIGEST"]) + ) def test_deploy_workflow_verifies_service_and_job_image_refs_from_resource_configs(self): - content = read_workflow("deploy-production.yml") + doc = load_workflow("deploy-production.yml") - self.assertIn("--format='value(spec.template.spec.containers[0].image)'", content) - self.assertIn("--format='value(spec.template.spec.template.spec.containers[0].image)'", content) - self.assertIn('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]', content) - self.assertIn('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]', content) + runs = step_run_texts(job_steps(doc["jobs"]["deploy"])) + service_runs = [ + run + for run in runs + if "value(spec.template.spec.containers[0].image)" in run + ] + job_runs = [ + run + for run in runs + if "value(spec.template.spec.template.spec.containers[0].image)" + in run + ] + self.assertTrue( + service_runs, "no deploy step verifies the service image-ref" + ) + self.assertTrue(job_runs, "no deploy step verifies the job image-ref") + self.assertTrue( + any('if [[ "${service_image_ref}" != "${IMAGE_REF}" ]]' in run for run in runs), + "no deploy step guards on the service image-ref mismatch", + ) + self.assertTrue( + any('if [[ "${job_image_ref}" != "${IMAGE_REF}" ]]' in run for run in runs), + "no deploy step guards on the job image-ref mismatch", + ) def test_deploy_workflow_sets_up_buildx_before_using_gha_cache(self): - content = read_workflow("deploy-production.yml") - - build_section = content.split(" deploy:", maxsplit=1)[0] + doc = load_workflow("deploy-production.yml") - self.assertIn("uses: docker/setup-buildx-action@v3", build_section) - self.assertIn("driver: docker-container", build_section) - self.assertIn("cache-from: type=gha", build_section) - self.assertIn("cache-to: type=gha,mode=max", build_section) - self.assertLess( - build_section.index("uses: docker/setup-buildx-action@v3"), - build_section.index("uses: docker/build-push-action@v6"), + steps = job_steps(doc["jobs"]["build"]) + uses = [step.get("uses") or "" for step in steps if isinstance(step, dict)] + buildx_index = next( + i for i, entry in enumerate(uses) if "docker/setup-buildx-action" in entry + ) + build_index = next( + i for i, entry in enumerate(uses) if "docker/build-push-action" in entry ) + self.assertLess(buildx_index, build_index) + + buildx_step = steps[buildx_index] + self.assertIn("docker-container", str((buildx_step.get("with") or {}).get("driver"))) + + build_with = steps[build_index].get("with") or {} + self.assertIn("cache-from", build_with) + self.assertIn("cache-to", build_with) + self.assertIn("type=gha", str(build_with["cache-from"])) + self.assertIn("type=gha,mode=max", str(build_with["cache-to"])) def test_rollback_workflow_exists_and_uses_workflow_dispatch(self): - content = read_workflow("rollback-production.yml") + doc = load_workflow("rollback-production.yml") - self.assertIn("on:", content) - self.assertIn("workflow_dispatch:", content) - self.assertIn("image_ref:", content) + inputs = doc["on"]["workflow_dispatch"]["inputs"] + self.assertIn("image_ref", inputs) + self.assertEqual("string", inputs["image_ref"].get("type")) def test_rollback_workflow_defines_validate_and_rollback_jobs(self): - content = read_workflow("rollback-production.yml") + doc = load_workflow("rollback-production.yml") - self.assertIn("jobs:", content) - self.assertIn("validate:", content) - self.assertIn("rollback:", content) - self.assertIn("environment: production", content) + jobs = doc["jobs"] + for expected in ("validate", "rollback"): + self.assertIn(expected, jobs) + self.assertEqual("production", jobs["rollback"].get("environment")) def test_rollback_workflow_validates_input_without_gcp_auth_and_updates_both_resources_after_gate(self): - content = read_workflow("rollback-production.yml") - rollback_service_section = content.split(" - name: Deploy Cloud Run Service", maxsplit=1)[1] + doc = load_workflow("rollback-production.yml") - self.assertIn("asia-northeast1-docker.pkg.dev/star-light-breaker/comic-crawler/comic-crawler@sha256:", content) - self.assertIn("gcloud run jobs update comic-crawler-job", content) - self.assertIn("gcloud run deploy comic-crawler-service", content) - self.assertIn( - "DISCORD_BOT_TOKEN_SECRET_VERSION=${DISCORD_BOT_TOKEN_SECRET_VERSION}", - rollback_service_section, + validate_steps = job_steps(doc["jobs"]["validate"]) + for step in validate_steps: + uses = step.get("uses") or "" + name = step.get("name") or "" + run = step.get("run") or "" + self.assertNotIn("google-github-actions/auth", uses) + self.assertNotIn("Authenticate to Google Cloud", name) + self.assertNotIn("gcloud artifacts docker images describe", run) + + runs = step_run_texts(job_steps(doc["jobs"]["rollback"])) + self.assertTrue( + any("gcloud artifacts docker images describe" in run for run in runs), + "no rollback step describes the rollback target in Artifact Registry", + ) + self.assertTrue( + any("gcloud run jobs update comic-crawler-job" in run for run in runs), + "no rollback step updates the Cloud Run Job resource", + ) + self.assertTrue( + any("gcloud run deploy comic-crawler-service" in run for run in runs), + "no rollback step updates the Cloud Run Service resource", ) - validate_section = content.split(" rollback:", maxsplit=1)[0] - self.assertNotIn("Authenticate to Google Cloud", validate_section) - self.assertNotIn("gcloud artifacts docker images describe", validate_section) - self.assertIn("gcloud artifacts docker images describe", content.split(" rollback:", maxsplit=1)[1]) if __name__ == "__main__": diff --git a/tests/test_migrate_storage.py b/tests/test_migrate_storage.py index 9940b97..43be272 100644 --- a/tests/test_migrate_storage.py +++ b/tests/test_migrate_storage.py @@ -6,7 +6,7 @@ from manga_watch.firestore_storage import FirestoreStorageConfig, FirestoreStorageRepository from manga_watch.migrate_storage import migrate_storage -from tests.test_firestore_storage import FakeFirestoreClient, make_state, make_watchlist +from tests._firestore_fakes import FakeFirestoreClient, make_state, make_watchlist class MigrateStorageTests(unittest.TestCase): diff --git a/tests/test_print_cloud_scheduler_job.py b/tests/test_print_cloud_scheduler_job.py index 29806b0..75f7386 100644 --- a/tests/test_print_cloud_scheduler_job.py +++ b/tests/test_print_cloud_scheduler_job.py @@ -55,17 +55,17 @@ def test_build_run_request_body_sets_scheduled_trigger_override(self): module.build_run_request_body(), ) - def test_build_gcloud_command_for_create_requires_schedule(self): + def test_build_gcloud_command_for_create_requires_non_empty_schedule(self): module = load_scheduler_helper_module() - with self.assertRaisesRegex(ValueError, "schedule"): - module.build_gcloud_scheduler_http_command(action="create") - - def test_build_gcloud_command_for_create_rejects_whitespace_only_schedule(self): - module = load_scheduler_helper_module() - - with self.assertRaisesRegex(ValueError, "schedule"): - module.build_gcloud_scheduler_http_command(action="create", schedule=" ") + cases = { + "missing": {}, + "whitespace_only": {"schedule": " "}, + } + for case, kwargs in cases.items(): + with self.subTest(case=case): + with self.assertRaisesRegex(ValueError, "schedule"): + module.build_gcloud_scheduler_http_command(action="create", **kwargs) def test_build_gcloud_command_for_create_includes_oauth_and_json_body(self): module = load_scheduler_helper_module() @@ -127,20 +127,26 @@ def test_script_prints_update_command_with_canonical_defaults(self): result.stdout, ) - def test_script_create_requires_schedule_without_traceback(self): + def test_script_create_requires_non_empty_schedule_without_traceback(self): repo_root = Path(__file__).resolve().parents[1] - result = subprocess.run( - [sys.executable, "scripts/print_cloud_scheduler_job.py", "create"], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("--schedule must be a non-empty cron expression", result.stderr) - self.assertNotIn("Traceback", result.stderr) + cases = { + "missing": ["create"], + "whitespace_only": ["create", "--schedule", " "], + } + for case, args in cases.items(): + with self.subTest(case=case): + result = subprocess.run( + [sys.executable, "scripts/print_cloud_scheduler_job.py", *args], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + + self.assertNotEqual(0, result.returncode) + self.assertIn("--schedule must be a non-empty cron expression", result.stderr) + self.assertNotIn("Traceback", result.stderr) def test_build_gcloud_command_for_create_uses_create_headers_flag(self): module = load_scheduler_helper_module() @@ -161,21 +167,6 @@ def test_build_gcloud_command_for_update_uses_update_headers_flag(self): self.assertIn("--update-headers=Content-Type=application/json", command) self.assertNotIn("--headers=Content-Type=application/json", command) - def test_script_create_requires_non_empty_schedule_without_traceback(self): - repo_root = Path(__file__).resolve().parents[1] - - result = subprocess.run( - [sys.executable, "scripts/print_cloud_scheduler_job.py", "create", "--schedule", " "], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - ) - - self.assertNotEqual(0, result.returncode) - self.assertIn("--schedule must be a non-empty cron expression", result.stderr) - self.assertNotIn("Traceback", result.stderr) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_repair_mojibake.py b/tests/test_repair_mojibake.py index f06547e..d720c40 100644 --- a/tests/test_repair_mojibake.py +++ b/tests/test_repair_mojibake.py @@ -25,23 +25,22 @@ class RepairMojibakeHeuristicTests(unittest.TestCase): def test_garbled_title_round_trips_to_clean_title(self): self.assertEqual(repair_mojibake(garbled(CLEAN_TITLE)), CLEAN_TITLE) - def test_clean_japanese_unchanged(self): - self.assertEqual(repair_mojibake(CLEAN_TITLE), CLEAN_TITLE) - - def test_ascii_url_unchanged(self): - url = "https://championcross.jp/episodes/939a4960ab2ce" - self.assertEqual(repair_mojibake(url), url) - - def test_accented_european_unchanged(self): - self.assertEqual(repair_mojibake("café"), "café") - - def test_mixed_string_with_clean_japanese_unchanged(self): - mixed = "新着エピソードを検知しました(2026-08-11)" + garbled(CLEAN_TITLE) - self.assertEqual(repair_mojibake(mixed), mixed) - - def test_non_str_input_unchanged(self): - self.assertIs(repair_mojibake(None), None) - self.assertEqual(repair_mojibake(123), 123) + def test_noop_inputs_unchanged(self): + cases = { + "clean_japanese": CLEAN_TITLE, + "ascii_url": "https://championcross.jp/episodes/939a4960ab2ce", + "accented_european": "café", + "mixed_clean_japanese": ( + "新着エピソードを検知しました(2026-08-11)" + garbled(CLEAN_TITLE) + ), + } + for case, value in cases.items(): + with self.subTest(case=case): + self.assertEqual(repair_mojibake(value), value) + with self.subTest(case="non_str_none"): + self.assertIs(repair_mojibake(None), None) + with self.subTest(case="non_str_int"): + self.assertEqual(repair_mojibake(123), 123) def build_state(): diff --git a/tests/test_run_service.py b/tests/test_run_service.py index e3b783e..d311730 100644 --- a/tests/test_run_service.py +++ b/tests/test_run_service.py @@ -65,56 +65,49 @@ def test_build_http_response_returns_ok_for_healthz_get(self): self.assertEqual("text/plain; charset=utf-8", response.content_type) self.assertEqual([], service.calls) - def test_build_http_response_delegates_non_health_requests_to_interaction_service(self): - service = FakeInteractionService() - - response = run_service.build_http_response( - service, - method="POST", - path="/", - headers={"X-Test": "1"}, - body=b'{"type":1}', - ) - - self.assertEqual(200, response.status_code) - self.assertEqual(b"delegated", response.body) - self.assertEqual( - [ - { - "method": "POST", - "path": "/", - "headers": {"X-Test": "1"}, - "body": b'{"type":1}', - } - ], - service.calls, - ) - - def test_build_http_response_delegates_when_health_path_matches_interaction_path(self): - service = FakeInteractionService() - service.interaction_path = "/healthz" - - response = run_service.build_http_response( - service, - method="GET", - path="/healthz", - headers={"X-Test": "1"}, - body=b"", - ) - - self.assertEqual(200, response.status_code) - self.assertEqual(b"delegated", response.body) - self.assertEqual( - [ - { - "method": "GET", - "path": "/healthz", - "headers": {"X-Test": "1"}, - "body": b"", - } - ], - service.calls, - ) + def test_build_http_response_delegates_non_health_requests(self): + cases = { + "post_to_root": { + "interaction_path": "/", + "method": "POST", + "path": "/", + "headers": {"X-Test": "1"}, + "body": b'{"type":1}', + }, + "health_path_matches_interaction_path": { + "interaction_path": "/healthz", + "method": "GET", + "path": "/healthz", + "headers": {"X-Test": "1"}, + "body": b"", + }, + } + for case, request in cases.items(): + with self.subTest(case=case): + service = FakeInteractionService() + service.interaction_path = request["interaction_path"] + + response = run_service.build_http_response( + service, + method=request["method"], + path=request["path"], + headers=request["headers"], + body=request["body"], + ) + + self.assertEqual(200, response.status_code) + self.assertEqual(b"delegated", response.body) + self.assertEqual( + [ + { + "method": request["method"], + "path": request["path"], + "headers": request["headers"], + "body": request["body"], + } + ], + service.calls, + ) def test_main_boots_with_cloud_run_job_backend_minimal_env(self): public_key = SigningKey.generate().verify_key.encode().hex() diff --git a/tests/test_runner.py b/tests/test_runner.py index b5b0453..5119860 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -146,26 +146,6 @@ def test_runner_module_runs_until_config_validation(self): self.assertIn("[runner] configuration error:", result.stderr) self.assertIn("MANGA_WATCH_NOTIFIER_BACKENDS", result.stderr) - def test_run_once_records_run_summary_when_recorder_is_injected(self): - recorded = [] - - def checker(_watchlist_path): - return {"updates": [], "errors": {"sources": [], "run": []}} - - outcome = run_once( - self.make_config(), - checker=checker, - state_loader=lambda: {"version": 2, "works": {}, "last_run_at": None, "notification_outbox": [], "discord_delivery": {"daily_notification": {"delivered_latest_keys": {}, "pending_messages": []}}}, - state_saver=lambda _state: None, - run_recorder=lambda summary: recorded.append(dict(summary)) or "run-1", - report_logger=lambda _message: None, - error_logger=lambda _message: None, - ) - - self.assertEqual("run-1", outcome["runId"]) - self.assertEqual(1, len(recorded)) - self.assertTrue(recorded[0]["ok"]) - def make_config(self, *, with_discord=False): return RunnerConfig( timezone_name="Asia/Tokyo", @@ -352,27 +332,30 @@ def test_build_notifier_fans_out_to_all_configured_backends(self): self.assertEqual(1, len(session.calls)) self.assertEqual(event.as_payload(), session.calls[0]["json"]) - def test_webhook_notifier_raises_on_non_2xx(self): - event = build_update_event( - self.make_update(latest_key="episode-2"), - detected_at="2023-11-14T22:13:20Z", - ) - session = FakeSession(responses=[FakeResponse(500, "server exploded")]) - notifier = WebhookNotifier("https://example.com/hook", session=session) - - with self.assertRaisesRegex(RuntimeError, "Webhook returned HTTP 500"): - notifier.send(event) + def test_webhook_notifier_raises_on_delivery_failure(self): + cases = [ + ( + "non_2xx", + FakeSession(responses=[FakeResponse(500, "server exploded")]), + "Webhook returned HTTP 500", + ), + ( + "transport_error", + FakeSession(error=requests.Timeout("timed out")), + "Webhook delivery failed", + ), + ] - def test_webhook_notifier_raises_on_transport_error(self): - event = build_update_event( - self.make_update(latest_key="episode-2"), - detected_at="2023-11-14T22:13:20Z", - ) - session = FakeSession(error=requests.Timeout("timed out")) - notifier = WebhookNotifier("https://example.com/hook", session=session) + for case, session, expected_message in cases: + with self.subTest(case=case): + event = build_update_event( + self.make_update(latest_key="episode-2"), + detected_at="2023-11-14T22:13:20Z", + ) + notifier = WebhookNotifier("https://example.com/hook", session=session) - with self.assertRaisesRegex(RuntimeError, "Webhook delivery failed"): - notifier.send(event) + with self.assertRaisesRegex(RuntimeError, expected_message): + notifier.send(event) def test_webhook_notifier_masks_webhook_url_in_transport_error(self): webhook_url = "https://discord.com/api/webhooks/123/secret" @@ -458,15 +441,19 @@ def test_run_once_records_run_summary(self): checker=lambda _: {"updates": [self.make_update()]}, state_loader=self.make_state, state_saver=lambda _: None, - run_recorder=recorded.append, + run_recorder=lambda summary: recorded.append(dict(summary)) or "run-1", now_fn=lambda: 1_700_000_000, report_logger=lambda _: None, error_logger=lambda _: self.fail("unexpected error log"), ) self.assertTrue(outcome["ok"]) + self.assertEqual("run-1", outcome["runId"]) self.assertEqual(1, len(recorded)) - self.assertEqual(outcome, recorded[0]) + self.assertTrue(recorded[0]["ok"]) + expected_recorded = dict(outcome) + expected_recorded.pop("runId") + self.assertEqual(expected_recorded, recorded[0]) def test_run_once_reports_failure_when_run_recorder_raises(self): reports = [] @@ -666,35 +653,6 @@ def test_run_once_logs_failure_report_when_notifier_fails(self): self.assertIn("巡回実行に失敗しました", errors[0]) self.assertIn("notifier backend failed", errors[0]) - def test_run_once_continues_delivering_valid_updates_when_one_payload_is_invalid(self): - notifier = FakeNotifier() - errors = [] - invalid_update = self.make_update() - invalid_update["to"] = { - "series_title": "作品A", - "episode_title": "第2話", - "update_type": "main_story", - "default_notify": True, - } - - outcome = run_once( - self.make_config(), - notifier=notifier, - checker=lambda _: {"updates": [invalid_update, self.make_update(latest_key="episode-3")]}, - state_loader=self.make_state, - state_saver=lambda _: None, - now_fn=lambda: 1_700_000_000, - report_logger=lambda _: self.fail("unexpected report log"), - error_logger=errors.append, - ) - - self.assertFalse(outcome["ok"]) - self.assertEqual(2, outcome["notifiedUpdateCount"]) - self.assertEqual(1, len(notifier.events)) - self.assertEqual("episode-3", notifier.events[0].latest_key) - self.assertEqual(1, len(errors)) - self.assertIn("work-1: update event work-1 is missing latest_key", errors[0]) - def test_generic_notification_phase_continues_delivering_valid_updates_when_one_payload_is_invalid(self): notifier = FakeNotifier() errors = {"sources": [], "run": []} @@ -1131,49 +1089,15 @@ def checker(_): self.assertEqual(1, len(reports)) self.assertEqual([], notifier.events) - def test_run_coordinator_queues_startup_while_fetch_is_in_progress(self): - checker_started = threading.Event() - allow_finish = threading.Event() - reports = [] - call_count = {"value": 0} - - def checker(_): - call_count["value"] += 1 - if call_count["value"] == 1: - checker_started.set() - allow_finish.wait(1.0) - return {"updates": []} - - coordinator = RunCoordinator( - self.make_config(), - notifier=FakeNotifier(), - checker=checker, - state_loader=self.make_state, - state_saver=lambda _: None, - now_fn=lambda: 1_700_000_000, - report_logger=reports.append, - error_logger=lambda _: self.fail("unexpected error log"), - ) - - fetch_outcome = start_fetch_run(coordinator) - self.assertTrue(fetch_outcome["accepted"]) - self.assertTrue(checker_started.wait(0.5)) - - startup_outcome = coordinator.run(TRIGGER_SOURCE_STARTUP) - - self.assertTrue(startup_outcome["ok"]) - self.assertTrue(startup_outcome["accepted"]) - self.assertTrue(startup_outcome["queued"]) - self.assertTrue(startup_outcome["serialized"]) - self.assertEqual(TRIGGER_SOURCE_STARTUP, startup_outcome["triggerSource"]) - - allow_finish.set() - self.wait_until(lambda: not coordinator.is_running()) - self.assertEqual(2, call_count["value"]) - self.assertEqual(2, len(reports)) - self.assertIn("トリガー: startup", reports[1]) + def test_run_coordinator_queues_second_trigger_while_fetch_is_in_progress(self): + for trigger_source, trigger_label in ( + (TRIGGER_SOURCE_STARTUP, "startup"), + (TRIGGER_SOURCE_SCHEDULED, "scheduled"), + ): + with self.subTest(trigger_source=trigger_source): + self._assert_run_coordinator_queues_second_trigger(trigger_source, trigger_label) - def test_run_coordinator_queues_scheduled_while_fetch_is_in_progress(self): + def _assert_run_coordinator_queues_second_trigger(self, trigger_source, trigger_label): checker_started = threading.Event() allow_finish = threading.Event() reports = [] @@ -1201,19 +1125,19 @@ def checker(_): self.assertTrue(fetch_outcome["accepted"]) self.assertTrue(checker_started.wait(0.5)) - scheduled_outcome = coordinator.run(TRIGGER_SOURCE_SCHEDULED) + second_outcome = coordinator.run(trigger_source) - self.assertTrue(scheduled_outcome["ok"]) - self.assertTrue(scheduled_outcome["accepted"]) - self.assertTrue(scheduled_outcome["queued"]) - self.assertTrue(scheduled_outcome["serialized"]) - self.assertEqual(TRIGGER_SOURCE_SCHEDULED, scheduled_outcome["triggerSource"]) + self.assertTrue(second_outcome["ok"]) + self.assertTrue(second_outcome["accepted"]) + self.assertTrue(second_outcome["queued"]) + self.assertTrue(second_outcome["serialized"]) + self.assertEqual(trigger_source, second_outcome["triggerSource"]) allow_finish.set() self.wait_until(lambda: not coordinator.is_running()) self.assertEqual(2, call_count["value"]) self.assertEqual(2, len(reports)) - self.assertIn("トリガー: scheduled", reports[1]) + self.assertIn(f"トリガー: {trigger_label}", reports[1]) def test_handle_fetch_trigger_accepts_again_after_failure(self): call_count = {"value": 0} diff --git a/tests/test_source_drift.py b/tests/test_source_drift.py index 566b0b4..8838e04 100644 --- a/tests/test_source_drift.py +++ b/tests/test_source_drift.py @@ -98,87 +98,64 @@ def test_broken_fixture_reports_drift_with_refresh_hint(self): result.next_action, ) - def test_piccoma_canary_requires_ld_json_product_name(self): - manifest, client = load_fixture_http_client("piccoma", "normal") - product_without_ld_json = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text( + def test_piccoma_canary_rejects_mutated_product_signals(self): + product_html = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text( encoding="utf-8" ) - product_without_ld_json = product_without_ld_json.replace( - """ + mutations = ( + ( + "missing_ld_json", + 0, + product_html.replace( + """ """, - "", - ) - client.steps[0] = { - "url": manifest["steps"][0]["url"], - "body": product_without_ld_json, - } - contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"] - contract = contract.__class__( - source=contract.source, - seed_url=manifest["seedUrl"], - fixture_bundle=contract.fixture_bundle, - monitored_signals=contract.monitored_signals, - ) - - result = run_source_canary(contract, http_client=client) - - self.assertEqual("drift", result.status) - self.assertEqual("SourceParseError", result.error_type) - self.assertIn("LD JSON Product.name not found", result.message) - - def test_piccoma_canary_rejects_non_product_ld_json_name(self): - manifest, client = load_fixture_http_client("piccoma", "normal") - product_with_non_product_ld_json = (FIXTURES_ROOT / "piccoma" / "normal" / "01-product.html").read_text( - encoding="utf-8" - ) - product_with_non_product_ld_json = product_with_non_product_ld_json.replace( - '{"@type":"Product","name":"九条の大罪"}', - '{"@type":"BreadcrumbList","name":"九条の大罪"}', - ) - client.steps[0] = { - "url": manifest["steps"][0]["url"], - "body": product_with_non_product_ld_json, - } - contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"] - contract = contract.__class__( - source=contract.source, - seed_url=manifest["seedUrl"], - fixture_bundle=contract.fixture_bundle, - monitored_signals=contract.monitored_signals, - ) - - result = run_source_canary(contract, http_client=client) - - self.assertEqual("drift", result.status) - self.assertEqual("SourceParseError", result.error_type) - self.assertIn("LD JSON Product.name not found", result.message) - - def test_piccoma_canary_requires_episode_list_latest_identifier(self): - manifest, client = load_fixture_http_client("piccoma", "normal") - episodes_without_list = """ + "", + ), + "LD JSON Product.name not found", + ), + ( + "non_product_ld_json", + 0, + product_html.replace( + '{"@type":"Product","name":"九条の大罪"}', + '{"@type":"BreadcrumbList","name":"九条の大罪"}', + ), + "LD JSON Product.name not found", + ), + ( + "missing_episode_list", + 1, + """ - """ - client.steps[1] = { - "url": manifest["steps"][1]["url"], - "body": episodes_without_list, - } - contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"] - contract = contract.__class__( - source=contract.source, - seed_url=manifest["seedUrl"], - fixture_bundle=contract.fixture_bundle, - monitored_signals=contract.monitored_signals, + """, + "#js_episodeList latest episode identifier not found", + ), ) - result = run_source_canary(contract, http_client=client) + for mutation, step_index, body, expected_message in mutations: + with self.subTest(mutation=mutation): + manifest, client = load_fixture_http_client("piccoma", "normal") + client.steps[step_index] = { + "url": manifest["steps"][step_index]["url"], + "body": body, + } + contract = DEFAULT_SOURCE_CANARY_CONTRACTS["piccoma"] + contract = contract.__class__( + source=contract.source, + seed_url=manifest["seedUrl"], + fixture_bundle=contract.fixture_bundle, + monitored_signals=contract.monitored_signals, + ) - self.assertEqual("drift", result.status) - self.assertEqual("SourceParseError", result.error_type) - self.assertIn("#js_episodeList latest episode identifier not found", result.message) + result = run_source_canary(contract, http_client=client) + + self.assertEqual("drift", result.status) + self.assertEqual("SourceParseError", result.error_type) + self.assertIn(expected_message, result.message) def test_main_returns_non_zero_when_a_canary_fails(self): failing_result = SourceCanaryResult( diff --git a/tests/test_source_search.py b/tests/test_source_search.py index c619d4b..c7cd500 100644 --- a/tests/test_source_search.py +++ b/tests/test_source_search.py @@ -21,64 +21,61 @@ def get_text(self, url: str) -> str: class SourceSearchTests(unittest.TestCase): - def test_search_source_parses_takecomic_results_and_strips_update_label(self): - query = "異世界の常識は難しい" - request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}" - html = ( - Path(__file__).parent - / "fixtures" - / "source_search" - / "takecomic_search_update_label.html" - ).read_text(encoding="utf-8") - - results = search_source( - "takecomic", - query, - http_client=StaticHttpClient({request_url: html}), - ) + def test_search_source_parses_takecomic_results(self): + cases = ( + { + "branch": "strips_update_label", + "query": "異世界の常識は難しい", + "html": ( + Path(__file__).parent + / "fixtures" + / "source_search" + / "takecomic_search_update_label.html" + ).read_text(encoding="utf-8"), + "expected": [ + SearchResult( + source="takecomic", + title="異世界の常識は難しい~希少で最弱な人族に転生したけど物理以外で最強になりそうです~", + seed_url="https://takecomic.jp/series/bb237f85f48a3", + subtitle="takecomic", + ) + ], + }, + { + "branch": "badge_only_fallback", + "query": "takecomic badge only", + "html": """ + + +
+
更新
+
+
+ + """, + "expected": [ + SearchResult( + source="takecomic", + title="https://takecomic.jp/series/bb237f85f48a3", + seed_url="https://takecomic.jp/series/bb237f85f48a3", + subtitle="takecomic", + ) + ], + }, + ) + + for entry in cases: + with self.subTest(branch=entry["branch"]): + query = entry["query"] + request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}" - self.assertEqual( - [ - SearchResult( - source="takecomic", - title="異世界の常識は難しい~希少で最弱な人族に転生したけど物理以外で最強になりそうです~", - seed_url="https://takecomic.jp/series/bb237f85f48a3", - subtitle="takecomic", + results = search_source( + "takecomic", + query, + http_client=StaticHttpClient({request_url: entry["html"]}), ) - ], - results, - ) - def test_search_source_falls_back_to_canonical_url_when_takecomic_title_is_only_badge(self): - query = "takecomic badge only" - request_url = f"https://takecomic.jp/search?keyword={quote_plus(query)}" - html = """ - - -
-
更新
-
-
- - """ - - results = search_source( - "takecomic", - query, - http_client=StaticHttpClient({request_url: html}), - ) - - self.assertEqual( - [ - SearchResult( - source="takecomic", - title="https://takecomic.jp/series/bb237f85f48a3", - seed_url="https://takecomic.jp/series/bb237f85f48a3", - subtitle="takecomic", - ) - ], - results, - ) + self.assertEqual(entry["expected"], results) def test_supported_search_sources_match_registered_sources(self): self.assertEqual( @@ -226,103 +223,97 @@ def test_search_source_prefers_champion_cross_series_card_over_noise_links(self) results, ) - def test_search_source_parses_comic_action_results_via_q_parameter(self): - html = """ - - -
-

「ダンジョンの中のひと」の検索結果

- -
- - - """ - - results = search_source( - "comic-action", - "ダンジョンの中のひと", - http_client=StaticHttpClient( - { - "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html - } - ), - ) - - self.assertEqual( - [ - SearchResult( - source="comic-action", - title="ダンジョンの中のひと", - seed_url="https://comic-action.com/rss/series/13933686331663374228", - subtitle="comic-action", + def test_search_source_parses_comic_action_results(self): + cases = ( + { + "branch": "series_thumbnail_seed", + "html": """ + + +
+

「ダンジョンの中のひと」の検索結果

+ +
+ + + """, + "expected": [ + SearchResult( + source="comic-action", + title="ダンジョンの中のひと", + seed_url="https://comic-action.com/rss/series/13933686331663374228", + subtitle="comic-action", + ) + ], + }, + { + "branch": "reordered_attributes_latest_link", + "html": """ + + +
+ +
+ + + """, + "expected": [ + SearchResult( + source="comic-action", + title="ダンジョンの中のひと", + seed_url="https://comic-action.com/episode/13933686331677886179", + subtitle="comic-action", + ) + ], + }, + ) + + for entry in cases: + with self.subTest(branch=entry["branch"]): + results = search_source( + "comic-action", + "ダンジョンの中のひと", + http_client=StaticHttpClient( + { + "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": entry[ + "html" + ] + } + ), ) - ], - results, - ) - def test_search_source_parses_comic_action_latest_link_when_attributes_are_reordered(self): - html = """ - - -
- -
- - - """ - - results = search_source( - "comic-action", - "ダンジョンの中のひと", - http_client=StaticHttpClient( - { - "https://comic-action.com/search?q=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html - } - ), - ) - - self.assertEqual( - [ - SearchResult( - source="comic-action", - title="ダンジョンの中のひと", - seed_url="https://comic-action.com/episode/13933686331677886179", - subtitle="comic-action", - ) - ], - results, - ) + self.assertEqual(entry["expected"], results) def test_search_source_parses_comic_earthstar_results_via_q_parameter(self): html = """ @@ -496,216 +487,162 @@ def test_search_source_percent_encodes_magapoke_queries_with_spaces(self): ) def test_search_source_parses_gaugau_results(self): - html = """ - - - - - - """ - - results = search_source( - "gaugau", - "ダンジョンの中のひと", - http_client=StaticHttpClient( - { - "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html - } - ), - ) - - self.assertEqual( - [ - SearchResult( - source="gaugau", - title="ダンジョンの中のひと", - seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000", - subtitle="gaugau", + cases = ( + { + "branch": "heading_over_empty_thumbnail", + "html": """ + + + + + + """, + }, + { + "branch": "heading_over_badge_inside_thumbnail_anchor", + "html": """ + + + + + + """, + }, + ) + + for entry in cases: + with self.subTest(branch=entry["branch"]): + results = search_source( + "gaugau", + "ダンジョンの中のひと", + http_client=StaticHttpClient( + { + "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": entry[ + "html" + ] + } + ), ) - ], - results, - ) - def test_search_source_parses_bookwalker_series_results(self): - html = """ - - - - - - """ - - results = search_source( - "bookwalker", - "くまぐらし", - http_client=StaticHttpClient( - {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": html} - ), - ) - - self.assertEqual( - [ - SearchResult( - source="bookwalker", - title="くまぐらし(MANGAバル コミックス)", - seed_url="https://bookwalker.jp/series/519222/list/", - subtitle="bookwalker", + self.assertEqual( + [ + SearchResult( + source="gaugau", + title="ダンジョンの中のひと", + seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000", + subtitle="gaugau", + ) + ], + results, ) - ], - results, - ) - - def test_search_source_uses_bookwalker_image_alt_when_title_anchor_is_absent(self): - html = """ - - -
    -
  • -
    - くまぐらし(MANGAバル コミックス) - -
    -
  • -
- - - """ - - results = search_source( - "bookwalker", - "くまぐらし", - http_client=StaticHttpClient( - {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": html} - ), - ) - self.assertEqual( - [ - SearchResult( - source="bookwalker", - title="くまぐらし(MANGAバル コミックス)", - seed_url="https://bookwalker.jp/series/519222/list/", - subtitle="bookwalker", + def test_search_source_parses_bookwalker_series_results(self): + cases = ( + { + "branch": "title_anchor", + "html": """ + + + + + + """, + }, + { + "branch": "image_alt_fallback", + "html": """ + + +
    +
  • +
    + くまぐらし(MANGAバル コミックス) + +
    +
  • +
+ + + """, + }, + ) + + for entry in cases: + with self.subTest(branch=entry["branch"]): + results = search_source( + "bookwalker", + "くまぐらし", + http_client=StaticHttpClient( + {"https://bookwalker.jp/search/?word=%E3%81%8F%E3%81%BE%E3%81%90%E3%82%89%E3%81%97&order=score": entry["html"]} + ), ) - ], - results, - ) - - def test_search_source_prefers_gaugau_series_heading_over_badge_inside_thumbnail_anchor(self): - html = """ - - - - - - """ - - results = search_source( - "gaugau", - "ダンジョンの中のひと", - http_client=StaticHttpClient( - { - "https://gaugau.futabanet.jp/list/search-result?word=%E3%83%80%E3%83%B3%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%AE%E4%B8%AD%E3%81%AE%E3%81%B2%E3%81%A8": html - } - ), - ) - self.assertEqual( - [ - SearchResult( - source="gaugau", - title="ダンジョンの中のひと", - seed_url="https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000", - subtitle="gaugau", + self.assertEqual( + [ + SearchResult( + source="bookwalker", + title="くまぐらし(MANGAバル コミックス)", + seed_url="https://bookwalker.jp/series/519222/list/", + subtitle="bookwalker", + ) + ], + results, ) - ], - results, - ) - def test_search_source_parses_piccoma_json_results(self): + def test_search_source_parses_piccoma_results(self): query = "九条の大罪" request_url = ( "https://piccoma.com/web/search/result_ajax/list" f"?tab_type=T&word={quote_plus(query)}&page=1" ) - json_text = (FIXTURES_ROOT / "piccoma" / "01-search.json").read_text(encoding="utf-8") - - results = search_source( - "piccoma", - query, - http_client=StaticHttpClient({request_url: json_text}), - ) - self.assertEqual( - [ - SearchResult( - source="piccoma", - title="九条の大罪", - seed_url="https://piccoma.com/web/product/58170?etype=episode", - subtitle="piccoma", - ), - SearchResult( - source="piccoma", - title="闇金ウシジマくん", - seed_url="https://piccoma.com/web/product/12345?etype=episode", - subtitle="piccoma", - ), - ], - results, - ) - - def test_search_source_piccoma_ignores_malformed_rows(self): - query = "九条の大罪" - request_url = ( - "https://piccoma.com/web/search/result_ajax/list" - f"?tab_type=T&word={quote_plus(query)}&page=1" - ) - json_text = """ + valid_json = (FIXTURES_ROOT / "piccoma" / "01-search.json").read_text(encoding="utf-8") + malformed_rows_json = """ { "status": 0, "products": [ @@ -719,47 +656,68 @@ def test_search_source_piccoma_ignores_malformed_rows(self): ] } """ - - results = search_source( - "piccoma", - query, - http_client=StaticHttpClient({request_url: json_text}), - ) - - self.assertEqual( - [ - SearchResult( - source="piccoma", - title="九条の大罪", - seed_url="https://piccoma.com/web/product/58170?etype=episode", - subtitle="piccoma", - ) - ], - results, - ) - - def test_search_source_piccoma_returns_empty_for_empty_or_malformed_products(self): - query = "九条の大罪" - request_url = ( - "https://piccoma.com/web/search/result_ajax/list" - f"?tab_type=T&word={quote_plus(query)}&page=1" - ) - - for json_text in ( + empty_variants = ( '{"status": 0, "products": []}', '{"status": 0, "products": {}}', '{"status": 1, "products": [{"id": 58170, "title": "九条の大罪"}]}', '{"status": 0}', 'not json', - ): - with self.subTest(json_text=json_text): - results = search_source( - "piccoma", - query, - http_client=StaticHttpClient({request_url: json_text}), - ) + ) - self.assertEqual([], results) + with self.subTest(branch="valid_results"): + results = search_source( + "piccoma", + query, + http_client=StaticHttpClient({request_url: valid_json}), + ) + + self.assertEqual( + [ + SearchResult( + source="piccoma", + title="九条の大罪", + seed_url="https://piccoma.com/web/product/58170?etype=episode", + subtitle="piccoma", + ), + SearchResult( + source="piccoma", + title="闇金ウシジマくん", + seed_url="https://piccoma.com/web/product/12345?etype=episode", + subtitle="piccoma", + ), + ], + results, + ) + + with self.subTest(branch="malformed_rows"): + results = search_source( + "piccoma", + query, + http_client=StaticHttpClient({request_url: malformed_rows_json}), + ) + + self.assertEqual( + [ + SearchResult( + source="piccoma", + title="九条の大罪", + seed_url="https://piccoma.com/web/product/58170?etype=episode", + subtitle="piccoma", + ) + ], + results, + ) + + with self.subTest(branch="empty_or_malformed"): + for json_text in empty_variants: + with self.subTest(json_text=json_text): + results = search_source( + "piccoma", + query, + http_client=StaticHttpClient({request_url: json_text}), + ) + + self.assertEqual([], results) def test_search_source_falls_back_to_comicborder_homepage_results_when_search_errors(self): homepage_html = """ diff --git a/tests/test_sources.py b/tests/test_sources.py index 197b9c7..41bd733 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -11,14 +11,12 @@ REGISTERED_ADAPTERS, REGISTERED_SOURCES, SourceAdapter, - fetch_latest_for_work, normalize_seed_url, ) from manga_watch.sources.bookwalker import BookwalkerAdapter from manga_watch.sources.base import SourceParseError, WorkDescriptor from manga_watch.sources.champion_cross import ChampionCrossAdapter from manga_watch.sources.comic_action import ComicActionAdapter -from manga_watch.sources.comic_earthstar import ComicEarthstarAdapter from manga_watch.sources.comic_trail import parse_comic_trail_title from manga_watch.sources.comic_walker import ComicWalkerAdapter from manga_watch.sources.gaugau import GaugauAdapter @@ -27,7 +25,6 @@ from manga_watch.sources.nicovideo_manga import NicovideoMangaAdapter from manga_watch.sources.piccoma import PiccomaAdapter from manga_watch.sources.shonenjumpplus import ShonenJumpPlusAdapter -from manga_watch.sources.sunday_webry import SundayWebryAdapter from manga_watch.sources.util import html_title from manga_watch.sources.takecomic import TakecomicAdapter @@ -218,7 +215,10 @@ def get_text(self, url: str) -> str: self.calls.append(url) if url not in self.responses: raise AssertionError(f"unexpected request: {url!r}") - return self.responses[url] + body = self.responses[url] + if isinstance(body, Exception): + raise body + return body def load_fixture_case(source: str, case_name: str): @@ -396,144 +396,560 @@ def test_magapoke_normalize_accepts_episode_url(self): work.to_dict(), ) - def test_feed_family_canonicalize_item_promotes_episode_seed_to_stable_series_descriptor(self): - cases = ( + def test_feed_family_normalize_and_canonicalize_matrix(self): + family = ( + { + "source": "comic-earthstar", + "episode_input": "https://comic-earthstar.com/episode/12207421983526541742?from=share", + "episode_url": "https://comic-earthstar.com/episode/12207421983526541742", + "rss_input": "https://comic-earthstar.com/rss/series/12207421983526538413?from=share", + "atom_input": "https://comic-earthstar.com/atom/series/12207421983526538413", + "rss_url": "https://comic-earthstar.com/rss/series/12207421983526538413", + "series_id": "12207421983526538413", + "canonical_input": "https://comic-earthstar.com/episode/12207421983526541742?from=share", + "normalize_cases": ("episode", "rss", "atom"), + }, + { + "source": "comicborder", + "episode_input": "https://comicborder.com/episode/12207421983437812169?from=share", + "episode_url": "https://comicborder.com/episode/12207421983437812169", + "rss_input": "https://comicborder.com/rss/series/12207421983437805229?from=share", + "atom_input": "https://comicborder.com/atom/series/12207421983437805229", + "rss_url": "https://comicborder.com/rss/series/12207421983437805229", + "series_id": "12207421983437805229", + "canonical_input": "https://comicborder.com/episode/12207421983437812169?from=share", + "normalize_cases": ("episode", "rss", "atom"), + }, + { + "source": "comic-trail", + "episode_input": "https://comic-trail.com/episode/2550689798402927313?from=share", + "episode_url": "https://comic-trail.com/episode/2550689798402927313", + "rss_input": "https://comic-trail.com/rss/series/14079602755560047206?from=share", + "atom_input": "https://comic-trail.com/atom/series/14079602755560047206?from=share", + "rss_url": "https://comic-trail.com/rss/series/14079602755560047206", + "series_id": "14079602755560047206", + "canonical_input": "https://comic-trail.com/episode/2550689798402927313?from=share", + "normalize_cases": ("episode", "rss", "atom"), + }, + { + "source": "comic-days", + "episode_url": "https://comic-days.com/episode/12207421983746014850", + "rss_url": "https://comic-days.com/rss/series/13933686331650127004", + "series_id": "13933686331650127004", + "canonical_input": "https://comic-days.com/episode/12207421983746014850?from=share", + "normalize_cases": (), + }, + { + "source": "kuragebunch", + "episode_input": "https://kuragebunch.com/episode/2550912964856491139?from=share", + "episode_url": "https://kuragebunch.com/episode/2550912964856491139", + "rss_input": "https://kuragebunch.com/rss/series/2550912964856487532?from=share", + "atom_input": "https://kuragebunch.com/atom/series/2550912964856487532", + "rss_url": "https://kuragebunch.com/rss/series/2550912964856487532", + "series_id": "2550912964856487532", + "canonical_input": "https://kuragebunch.com/episode/2550912964856491139?from=share", + "normalize_cases": ("episode", "rss", "atom"), + }, + { + "source": "shonenjumpplus", + "episode_url": "https://shonenjumpplus.com/episode/17107419589191805801", + "rss_url": "https://shonenjumpplus.com/rss/series/3269754496881854342", + "series_id": "3269754496881854342", + "canonical_input": "https://shonenjumpplus.com/episode/17107419589191805801?from=episode", + "normalize_cases": (), + }, + { + "source": "sunday-webry", + "episode_input": "https://www.sunday-webry.com/episode/12207421983581042977?from=share", + "episode_url": "https://www.sunday-webry.com/episode/12207421983581042977", + "rss_input": "https://www.sunday-webry.com/rss/series/12207421983580960894?from=share", + "atom_input": "https://www.sunday-webry.com/atom/series/12207421983580960894", + "rss_url": "https://www.sunday-webry.com/rss/series/12207421983580960894", + "series_id": "12207421983580960894", + "canonical_input": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode", + "normalize_cases": ("episode", "rss", "atom"), + }, + ) + + for entry in family: + source = entry["source"] + expected_episode = { + "source": source, + "kind": source, + "workId": entry["episode_url"], + "seedUrl": entry["episode_url"], + } + expected_feed = { + "source": source, + "kind": source, + "workId": f"{source}:{entry['series_id']}", + "seedUrl": entry["rss_url"], + "series": f"{source}:{entry['series_id']}", + "seriesId": entry["series_id"], + "feedKind": "rss", + } + inputs = { + "episode": entry.get("episode_input"), + "rss": entry.get("rss_input"), + "atom": entry.get("atom_input"), + } + for case in (*entry["normalize_cases"], "canonicalize"): + with self.subTest(source=source, case=case): + if case == "canonicalize": + item = normalize_seed_url(entry["canonical_input"]).to_dict() + client = StaticHttpClient( + { + item["seedUrl"]: ( + "" + f'' + "" + ), + } + ) + descriptor = ADAPTERS[source]().canonicalize_item(item, client).to_dict() + + self.assertEqual( + normalize_seed_url(entry["rss_url"]).to_dict(), + descriptor, + ) + self.assertEqual([item["seedUrl"]], client.calls) + elif case == "episode": + self.assertEqual( + expected_episode, + normalize_seed_url(inputs[case]).to_dict(), + ) + else: + self.assertEqual( + expected_feed, + normalize_seed_url(inputs[case]).to_dict(), + ) + + def _assert_fetch_cases(self, cases): + for entry in cases: + source = entry["source"] + with self.subTest(source=source, case=entry["case"]): + adapter = ADAPTERS[source]() + if "work" in entry: + work_id, seed_url, metadata = entry["work"] + work = WorkDescriptor( + source=source, + work_id=work_id, + seed_url=seed_url, + metadata=metadata, + ) + else: + work = adapter.normalize(entry["seed_url"]) + client = StaticHttpClient(entry["responses"]) + expected_error = entry.get("expected_error") + if expected_error is not None: + with self.assertRaisesRegex(SourceParseError, re.escape(expected_error)): + adapter.fetch_latest(work, client) + else: + latest = adapter.fetch_latest(work, client).to_dict() + for key, value in entry.get("expected_latest", {}).items(): + self.assertEqual(value, latest[key]) + for key in entry.get("missing_latest_keys", ()): + self.assertNotIn(key, latest) + if "expected_calls" in entry: + self.assertEqual(entry["expected_calls"], client.calls) + + def test_feed_family_fetch_latest_matrix(self): + self._assert_fetch_cases( ( - "comic-earthstar", - normalize_seed_url( - "https://comic-earthstar.com/episode/12207421983526541742?from=share" - ).to_dict(), { - "https://comic-earthstar.com/episode/12207421983526541742": """ - - - - - - """, + "source": "comic-trail", + "case": "canonical_feed_seed", + "work": ( + "comic-trail:14079602755560047206", + "https://comic-trail.com/rss/series/14079602755560047206", + { + "series": "comic-trail:14079602755560047206", + "seriesId": "14079602755560047206", + "feedKind": "rss", + }, + ), + "responses": { + "https://comic-trail.com/rss/series/14079602755560047206": """ + + + コミックトレイル(作品E) + + 第3話 新章 + https://comic-trail.com/episode/2550912965721039352?from=rss + 作品E + + + + """, + "https://comic-trail.com/episode/2550912965721039352": """ + + + 第3話 新章 / 作品E | コミックトレイル + + + 次回更新:毎月第2金曜 + + + """, + }, + "expected_latest": { + "workId": "comic-trail:14079602755560047206", + "series": "comic-trail:14079602755560047206", + "latestKey": "https://comic-trail.com/episode/2550912965721039352", + "seriesTitle": "作品E", + "episodeTitle": "第3話 新章", + "nextUpdateLabel": "次回更新:毎月第2金曜", + }, + "expected_calls": [ + "https://comic-trail.com/rss/series/14079602755560047206", + "https://comic-trail.com/episode/2550912965721039352", + ], }, - normalize_seed_url( - "https://comic-earthstar.com/rss/series/12207421983526538413" - ).to_dict(), - ), - ( - "comicborder", - normalize_seed_url( - "https://comicborder.com/episode/12207421983437812169?from=share" - ).to_dict(), { - "https://comicborder.com/episode/12207421983437812169": """ - - - - - - """, + "source": "shonenjumpplus", + "case": "backfills_series_title_from_page_title", + "work": ( + "shonenjumpplus:3269754496881854342", + "https://shonenjumpplus.com/rss/series/3269754496881854342", + { + "series": "shonenjumpplus:3269754496881854342", + "seriesId": "3269754496881854342", + "feedKind": "rss", + }, + ), + "responses": { + "https://shonenjumpplus.com/rss/series/3269754496881854342": """ + + + 少年ジャンプ+ + + [159話]マリッジトキシン + https://shonenjumpplus.com/episode/17107419589191805801 + + + + """, + "https://shonenjumpplus.com/episode/17107419589191805801": """ + + + [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+ + + + """, + }, + "expected_latest": { + "seriesTitle": "マリッジトキシン", + "episodeTitle": "[159話]マリッジトキシン", + }, }, - normalize_seed_url( - "https://comicborder.com/rss/series/12207421983437805229" - ).to_dict(), - ), - ( - "comic-trail", - normalize_seed_url( - "https://comic-trail.com/episode/2550689798402927313?from=share" - ).to_dict(), { - "https://comic-trail.com/episode/2550689798402927313": """ - - - - - - """, + "source": "shonenjumpplus", + "case": "episode_seed", + "seed_url": "https://shonenjumpplus.com/episode/17107419589191805801?from=episode", + "responses": { + "https://shonenjumpplus.com/episode/17107419589191805801": """ + + + [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+ + + + + + + + """, + "https://shonenjumpplus.com/rss/series/3269754496881854342": """ + + + 少年ジャンプ+(マリッジトキシン) + + [159話]マリッジトキシン + https://shonenjumpplus.com/episode/17107419589191805801 + マリッジトキシン + + + + """, + }, + "expected_latest": { + "latestKey": "https://shonenjumpplus.com/episode/17107419589191805801", + "seriesTitle": "マリッジトキシン", + "episodeTitle": "[159話]マリッジトキシン", + }, + "expected_calls": [ + "https://shonenjumpplus.com/episode/17107419589191805801", + "https://shonenjumpplus.com/rss/series/3269754496881854342", + "https://shonenjumpplus.com/episode/17107419589191805801", + ], }, - normalize_seed_url( - "https://comic-trail.com/rss/series/14079602755560047206" - ).to_dict(), - ), - ( - "comic-days", - normalize_seed_url( - "https://comic-days.com/episode/12207421983746014850?from=share" - ).to_dict(), { - "https://comic-days.com/episode/12207421983746014850": """ - - - - - - """, + "source": "comic-earthstar", + "case": "canonical_feed_seed", + "work": ( + "comic-earthstar:12207421983526538413", + "https://comic-earthstar.com/rss/series/12207421983526538413", + { + "series": "comic-earthstar:12207421983526538413", + "seriesId": "12207421983526538413", + "feedKind": "rss", + }, + ), + "responses": { + "https://comic-earthstar.com/rss/series/12207421983526538413": """ + + + コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~) + + 第2話② + https://comic-earthstar.com/episode/12207421983562435589 + 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ + + + + """, + "https://comic-earthstar.com/episode/12207421983562435589": """ + + + 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター + + + """, + }, + "expected_latest": { + "workId": "comic-earthstar:12207421983526538413", + "series": "comic-earthstar:12207421983526538413", + "latestKey": "https://comic-earthstar.com/episode/12207421983562435589", + "seriesTitle": "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~", + "episodeTitle": "第2話②", + }, + "expected_calls": [ + "https://comic-earthstar.com/rss/series/12207421983526538413", + "https://comic-earthstar.com/episode/12207421983562435589", + ], }, - normalize_seed_url( - "https://comic-days.com/rss/series/13933686331650127004" - ).to_dict(), - ), - ( - "kuragebunch", - normalize_seed_url( - "https://kuragebunch.com/episode/2550912964856491139?from=share" - ).to_dict(), { - "https://kuragebunch.com/episode/2550912964856491139": """ - - - - - - """, + "source": "comic-earthstar", + "case": "nested_parentheses_in_series_title_fallback", + "work": ( + "comic-earthstar:12207421983526538413", + "https://comic-earthstar.com/rss/series/12207421983526538413", + { + "series": "comic-earthstar:12207421983526538413", + "seriesId": "12207421983526538413", + "feedKind": "rss", + }, + ), + "responses": { + "https://comic-earthstar.com/rss/series/12207421983526538413": """ + + + コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~) + + 第2話② + https://comic-earthstar.com/episode/12207421983562435589 + + + + """, + "https://comic-earthstar.com/episode/12207421983562435589": """ + + + 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター + + + """, + }, + "expected_latest": { + "latestKey": "https://comic-earthstar.com/episode/12207421983562435589", + "episodeTitle": "第2話②", + "seriesTitle": "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~", + }, }, - normalize_seed_url( - "https://kuragebunch.com/rss/series/2550912964856487532" - ).to_dict(), - ), - ( - "shonenjumpplus", - normalize_seed_url( - "https://shonenjumpplus.com/episode/17107419589191805801?from=episode" - ).to_dict(), { - "https://shonenjumpplus.com/episode/17107419589191805801": """ - - - - - - """, + "source": "comicborder", + "case": "canonical_feed_seed", + "work": ( + "comicborder:12207421983437805229", + "https://comicborder.com/rss/series/12207421983437805229", + { + "series": "comicborder:12207421983437805229", + "seriesId": "12207421983437805229", + "feedKind": "rss", + }, + ), + "responses": { + "https://comicborder.com/rss/series/12207421983437805229": """ + + + コミックボーダー(マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道) + + 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ + https://comicborder.com/episode/12207421983437812169 + マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 + + + + """, + "https://comicborder.com/episode/12207421983437812169": """ + + + マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー + + + """, + }, + "expected_latest": { + "workId": "comicborder:12207421983437805229", + "series": "comicborder:12207421983437805229", + "latestKey": "https://comicborder.com/episode/12207421983437812169", + "seriesTitle": "マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道", + "episodeTitle": "第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~", + }, + "expected_calls": [ + "https://comicborder.com/rss/series/12207421983437805229", + "https://comicborder.com/episode/12207421983437812169", + ], + }, + { + "source": "sunday-webry", + "case": "episode_seed", + "seed_url": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode", + "responses": { + "https://www.sunday-webry.com/episode/12207421983581042977": """ + + + diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり + + + + + + + + """, + "https://www.sunday-webry.com/rss/series/12207421983580960894": """ + + + サンデーうぇぶり(しっぽと逆鱗) + + diary1 つきこと先生 + https://www.sunday-webry.com/episode/12207421983581042977 + しっぽと逆鱗 + + + + """, + }, + "expected_latest": { + "workId": "sunday-webry:12207421983580960894", + "series": "sunday-webry:12207421983580960894", + "latestKey": "https://www.sunday-webry.com/episode/12207421983581042977", + "seriesTitle": "しっぽと逆鱗", + "episodeTitle": "diary1 つきこと先生", + "pageTitle": "diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり", + }, + "expected_calls": [ + "https://www.sunday-webry.com/episode/12207421983581042977", + "https://www.sunday-webry.com/rss/series/12207421983580960894", + ], + }, + { + "source": "sunday-webry", + "case": "stale_episode_seed_uses_latest_page_title", + "seed_url": "https://www.sunday-webry.com/episode/12207421983581042977?from=episode", + "responses": { + "https://www.sunday-webry.com/episode/12207421983581042977": """ + + + diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり + + + + + + + """, + "https://www.sunday-webry.com/rss/series/12207421983580960894": """ + + + サンデーうぇぶり(しっぽと逆鱗) + + + https://www.sunday-webry.com/episode/12207421983581043001 + + + + + """, + "https://www.sunday-webry.com/episode/12207421983581043001": """ + + + diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり + + + + """, + }, + "expected_latest": { + "latestKey": "https://www.sunday-webry.com/episode/12207421983581043001", + "episodeTitle": "diary2 新しい話", + "seriesTitle": "しっぽと逆鱗", + "pageTitle": "diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり", + }, + "expected_calls": [ + "https://www.sunday-webry.com/episode/12207421983581042977", + "https://www.sunday-webry.com/rss/series/12207421983580960894", + "https://www.sunday-webry.com/episode/12207421983581043001", + ], }, - normalize_seed_url( - "https://shonenjumpplus.com/rss/series/3269754496881854342" - ).to_dict(), - ), - ( - "sunday-webry", - normalize_seed_url( - "https://www.sunday-webry.com/episode/12207421983581042977?from=episode" - ).to_dict(), { - "https://www.sunday-webry.com/episode/12207421983581042977": """ - - - - - - """, + "source": "kuragebunch", + "case": "canonical_feed_seed", + "work": ( + "kuragebunch:2550912964856487532", + "https://kuragebunch.com/rss/series/2550912964856487532", + { + "series": "kuragebunch:2550912964856487532", + "seriesId": "2550912964856487532", + "feedKind": "rss", + }, + ), + "responses": { + "https://kuragebunch.com/rss/series/2550912964856487532": """ + + + くらげバンチ(赤と青のガウン) + + 第15話 + https://kuragebunch.com/episode/12207421983430264919 + 赤と青のガウン + + + + """, + "https://kuragebunch.com/episode/12207421983430264919": """ + + + 赤と青のガウン - 彬子女王/池辺葵 / 第15話 | くらげバンチ + + + """, + }, + "expected_latest": { + "workId": "kuragebunch:2550912964856487532", + "series": "kuragebunch:2550912964856487532", + "latestKey": "https://kuragebunch.com/episode/12207421983430264919", + "seriesTitle": "赤と青のガウン", + "episodeTitle": "第15話", + }, + "expected_calls": [ + "https://kuragebunch.com/rss/series/2550912964856487532", + "https://kuragebunch.com/episode/12207421983430264919", + ], }, - normalize_seed_url( - "https://www.sunday-webry.com/rss/series/12207421983580960894" - ).to_dict(), ), ) - for source, item, responses, expected in cases: - with self.subTest(source=source): - client = StaticHttpClient(responses) - descriptor = ADAPTERS[source]().canonicalize_item(item, client).to_dict() - - self.assertEqual(expected, descriptor) - self.assertEqual([item["seedUrl"]], client.calls) - def test_magapoke_fetch_latest_reads_series_rss_from_title_page(self): adapter = MagapokeAdapter() work = adapter.normalize("https://pocket.shonenmagazine.com/title/03021/episode/427856") @@ -628,86 +1044,6 @@ def test_champion_cross_normalize_accepts_series_url(self): work.to_dict(), ) - def test_comic_trail_normalize_accepts_episode_and_feed_urls(self): - episode_work = normalize_seed_url("https://comic-trail.com/episode/2550689798402927313?from=share").to_dict() - rss_work = normalize_seed_url("https://comic-trail.com/rss/series/14079602755560047206?from=share").to_dict() - atom_work = normalize_seed_url("https://comic-trail.com/atom/series/14079602755560047206?from=share").to_dict() - - self.assertEqual( - { - "source": "comic-trail", - "kind": "comic-trail", - "workId": "https://comic-trail.com/episode/2550689798402927313", - "seedUrl": "https://comic-trail.com/episode/2550689798402927313", - }, - episode_work, - ) - expected_feed = { - "source": "comic-trail", - "kind": "comic-trail", - "workId": "comic-trail:14079602755560047206", - "seedUrl": "https://comic-trail.com/rss/series/14079602755560047206", - "series": "comic-trail:14079602755560047206", - "seriesId": "14079602755560047206", - "feedKind": "rss", - } - self.assertEqual(expected_feed, rss_work) - self.assertEqual(expected_feed, atom_work) - - def test_comic_trail_fetch_latest_accepts_canonical_feed_seed(self): - work = WorkDescriptor( - source="comic-trail", - work_id="comic-trail:14079602755560047206", - seed_url="https://comic-trail.com/rss/series/14079602755560047206", - metadata={ - "series": "comic-trail:14079602755560047206", - "seriesId": "14079602755560047206", - "feedKind": "rss", - }, - ) - client = StaticHttpClient( - { - "https://comic-trail.com/rss/series/14079602755560047206": """ - - - コミックトレイル(作品E) - - 第3話 新章 - https://comic-trail.com/episode/2550912965721039352?from=rss - 作品E - - - - """, - "https://comic-trail.com/episode/2550912965721039352": """ - - - 第3話 新章 / 作品E | コミックトレイル - - - 次回更新:毎月第2金曜 - - - """, - } - ) - - latest = fetch_latest_for_work(work, http_client=client).to_dict() - - self.assertEqual("comic-trail:14079602755560047206", latest["workId"]) - self.assertEqual("comic-trail:14079602755560047206", latest["series"]) - self.assertEqual("https://comic-trail.com/episode/2550912965721039352", latest["latestKey"]) - self.assertEqual("作品E", latest["seriesTitle"]) - self.assertEqual("第3話 新章", latest["episodeTitle"]) - self.assertEqual("次回更新:毎月第2金曜", latest["nextUpdateLabel"]) - self.assertEqual( - [ - "https://comic-trail.com/rss/series/14079602755560047206", - "https://comic-trail.com/episode/2550912965721039352", - ], - client.calls, - ) - def test_parse_comic_trail_title_strips_author_suffix_from_live_title_shape(self): page_title = ( "Pain.1 僕の好きな人 / 僕の彼女は春を売る - 後藤ねぎ | " @@ -719,45 +1055,6 @@ def test_parse_comic_trail_title_strips_author_suffix_from_live_title_shape(self parse_comic_trail_title(page_title), ) - def test_shonenjumpplus_fetch_latest_backfills_series_title_from_page_title(self): - work = WorkDescriptor( - source="shonenjumpplus", - work_id="shonenjumpplus:3269754496881854342", - seed_url="https://shonenjumpplus.com/rss/series/3269754496881854342", - metadata={ - "series": "shonenjumpplus:3269754496881854342", - "seriesId": "3269754496881854342", - "feedKind": "rss", - }, - ) - client = StaticHttpClient( - { - "https://shonenjumpplus.com/rss/series/3269754496881854342": """ - - - 少年ジャンプ+ - - [159話]マリッジトキシン - https://shonenjumpplus.com/episode/17107419589191805801 - - - - """, - "https://shonenjumpplus.com/episode/17107419589191805801": """ - - - [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+ - - - """, - } - ) - - latest = ShonenJumpPlusAdapter().fetch_latest(work, client).to_dict() - - self.assertEqual("マリッジトキシン", latest["seriesTitle"]) - self.assertEqual("[159話]マリッジトキシン", latest["episodeTitle"]) - def test_takecomic_normalize_accepts_series_url(self): work = TakecomicAdapter().normalize("https://takecomic.jp/series/3f846451aff2d/?ref=top") @@ -820,918 +1117,409 @@ def test_nicovideo_manga_normalize_accepts_sp_comic_url(self): ) def test_champion_cross_normalize_accepts_series_rss_url(self): - work = ChampionCrossAdapter().normalize("https://championcross.jp/series/abc123/rss?ref=top") - - self.assertEqual( - { - "source": "champion-cross", - "kind": "champion-cross", - "workId": "champion-cross:abc123", - "seedUrl": "https://championcross.jp/series/abc123/rss", - "series": "champion-cross:abc123", - "seriesHash": "abc123", - "feedKind": "rss", - }, - work.to_dict(), - ) - - def test_champion_cross_normalize_accepts_episode_url(self): - work = ChampionCrossAdapter().normalize("https://championcross.jp/episodes/ep12345/?page=1") - - self.assertEqual( - { - "source": "champion-cross", - "kind": "champion-cross", - "workId": "https://championcross.jp/episodes/ep12345", - "seedUrl": "https://championcross.jp/episodes/ep12345", - }, - work.to_dict(), - ) - - def test_comic_action_normalize_accepts_series_feed_url(self): - work = ComicActionAdapter().normalize("https://comic-action.com/rss/series/13933686331606207128?free_only=1") - - self.assertEqual( - { - "source": "comic-action", - "kind": "comic-action", - "workId": "comic-action:13933686331606207128", - "seedUrl": "https://comic-action.com/rss/series/13933686331606207128", - "series": "comic-action:13933686331606207128", - "seriesId": "13933686331606207128", - "feedKind": "rss", - }, - work.to_dict(), - ) - - def test_comic_action_fetch_latest_accepts_series_feed_url(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/atom/series/13933686331606207128") - client = StaticHttpClient( - { - "https://comic-action.com/atom/series/13933686331606207128": """ - - - - - - """, - "https://comic-action.com/episode/11341664176570134078": """ - - - 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション - - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("comic-action:13933686331606207128", latest["workId"]) - self.assertEqual( - "https://comic-action.com/episode/11341664176570134078", - latest["latestKey"], - ) - self.assertEqual("つぐもも", latest["seriesTitle"]) - self.assertEqual("第1話 母さんの形見", latest["episodeTitle"]) - self.assertEqual( - [ - "https://comic-action.com/atom/series/13933686331606207128", - "https://comic-action.com/episode/11341664176570134078", - ], - client.calls, - ) - - def test_comic_action_fetch_latest_extracts_next_update_label_from_latest_page(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/episode/111") - client = StaticHttpClient( - { - "https://comic-action.com/episode/111": """ - - 第1話 / 作品B - webアクション | comic-action - - - - - """, - "https://comic-action.com/episode/222": """ - - 第2話 / 作品B - webアクション | comic-action - -
-

次回更新: 4月3日

-
- - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("4月3日", latest["nextUpdateLabel"]) - - def test_comic_action_fetch_latest_uses_series_rss_for_episode_seed_when_readable_chain_stops(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/episode/2550689798784879524") - client = StaticHttpClient( - { - "https://comic-action.com/episode/2550689798784879524": """ - - 第39話 / ダンジョンの中のひと - 双見酔 | webアクション - - - - - - """, - "https://comic-action.com/rss/series/13933686331663374228": """ - - - - 第51話 - https://comic-action.com/episode/2551460910007760899 - - - 第50話 - https://comic-action.com/episode/2551460909780695609 - - - - """, - "https://comic-action.com/episode/2551460910007760899": """ - - 第51話 / ダンジョンの中のひと - 双見酔 | webアクション - - -
-

次回更新: 4月4日

-
- - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual( - "https://comic-action.com/episode/2551460910007760899", - latest["latestKey"], - ) - self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"]) - self.assertEqual("第51話", latest["episodeTitle"]) - self.assertEqual("4月4日", latest["nextUpdateLabel"]) - self.assertEqual( - [ - "https://comic-action.com/episode/2550689798784879524", - "https://comic-action.com/rss/series/13933686331663374228", - "https://comic-action.com/episode/2551460910007760899", - ], - client.calls, - ) - - def test_comic_action_fetch_latest_falls_back_to_entry_episode_when_series_rss_fetch_fails(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/episode/2550689798784879524") - - class Client: - def __init__(self): - self.calls = [] - - def get_text(self, url: str) -> str: - self.calls.append(url) - if url == "https://comic-action.com/episode/2550689798784879524": - return """ - - 第39話 / ダンジョンの中のひと - 双見酔 | webアクション - - - - - - """ - if url == "https://comic-action.com/rss/series/13933686331663374228": - raise RuntimeError("temporary feed outage") - raise AssertionError(f"unexpected request: {url!r}") - - client = Client() - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual( - "https://comic-action.com/episode/2550689798784879524", - latest["latestKey"], - ) - self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"]) - self.assertEqual("第39話", latest["episodeTitle"]) - self.assertEqual( - [ - "https://comic-action.com/episode/2550689798784879524", - "https://comic-action.com/rss/series/13933686331663374228", - ], - client.calls, - ) - - def test_comic_action_fetch_latest_keeps_entry_episode_when_series_rss_is_stale(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/episode/2550689798784879524") - client = StaticHttpClient( - { - "https://comic-action.com/episode/2550689798784879524": """ - - 第39話 / ダンジョンの中のひと - 双見酔 | webアクション - - - - - - """, - "https://comic-action.com/rss/series/13933686331663374228": """ - - - - 第50話 - https://comic-action.com/episode/2551460909780695609 - - - - """, - "https://comic-action.com/episode/2551460909780695609": """ - - 第50話 / ダンジョンの中のひと - 双見酔 | webアクション - - - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual( - "https://comic-action.com/episode/2550689798784879524", - latest["latestKey"], - ) - self.assertEqual("ダンジョンの中のひと", latest["seriesTitle"]) - self.assertEqual("第39話", latest["episodeTitle"]) - self.assertEqual( - [ - "https://comic-action.com/episode/2550689798784879524", - "https://comic-action.com/rss/series/13933686331663374228", - "https://comic-action.com/episode/2551460909780695609", - ], - client.calls, - ) - - def test_comic_walker_fetch_latest_extracts_next_update_label(self): - adapter = ComicWalkerAdapter() - work = adapter.normalize("https://comic-walker.com/detail/KC_123456_S") - client = StaticHttpClient( - { - "https://comic-walker.com/detail/KC_123456_S": """ - - - - - - """, - "https://comic-walker.com/detail/KC_123456_S/episodes/KC_123456003_E?episodeType=latest": """ - - 【作品A】第3話|カドコミ (コミックウォーカー) - -
次回更新予定日:未定
- - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("未定", latest["nextUpdateLabel"]) - - def test_kakuyomu_fetch_latest_extracts_next_update_label_from_schedule(self): - adapter = KakuyomuAdapter() - work = adapter.normalize("https://kakuyomu.jp/works/123") - client = StaticHttpClient( - { - "https://kakuyomu.jp/works/123": """ - - - - - - """, - "https://kakuyomu.jp/works/123/episodes/789": """ - - 第2話 - 作品C - カクヨム - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("毎日 12:08", latest["nextUpdateLabel"]) - - def test_kakuyomu_fetch_latest_allows_null_schedule(self): - adapter = KakuyomuAdapter() - work = adapter.normalize("https://kakuyomu.jp/works/123") - client = StaticHttpClient( - { - "https://kakuyomu.jp/works/123": """ - - - - - - """, - "https://kakuyomu.jp/works/123/episodes/789": """ - - 第2話 - 作品C - カクヨム - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertNotIn("nextUpdateLabel", latest) - - def test_comic_action_fetch_latest_accepts_www_episode_links_from_feed(self): - adapter = ComicActionAdapter() - work = adapter.normalize("https://comic-action.com/rss/series/13933686331606207128") - client = StaticHttpClient( - { - "https://comic-action.com/rss/series/13933686331606207128": """ - - - - https://www.comic-action.com/episode/11341664176570134078 - - - - """, - "https://comic-action.com/episode/11341664176570134078": """ - - - 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション - - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("comic-action:13933686331606207128", latest["workId"]) - self.assertEqual( - "https://comic-action.com/episode/11341664176570134078", - latest["latestKey"], - ) - self.assertEqual( - [ - "https://comic-action.com/rss/series/13933686331606207128", - "https://comic-action.com/episode/11341664176570134078", - ], - client.calls, - ) - - def test_shonenjumpplus_normalize_accepts_series_feed_urls(self): - adapter = ShonenJumpPlusAdapter() - - rss_work = adapter.normalize("https://shonenjumpplus.com/rss/series/3269754496881854342?from=share") - atom_work = adapter.normalize("https://shonenjumpplus.com/atom/series/3269754496881854342") - - expected = { - "source": "shonenjumpplus", - "kind": "shonenjumpplus", - "workId": "shonenjumpplus:3269754496881854342", - "seedUrl": "https://shonenjumpplus.com/rss/series/3269754496881854342", - "series": "shonenjumpplus:3269754496881854342", - "seriesId": "3269754496881854342", - "feedKind": "rss", - } - self.assertEqual(expected, rss_work.to_dict()) - self.assertEqual(expected, atom_work.to_dict()) - - def test_shonenjumpplus_fetch_latest_accepts_episode_seed(self): - adapter = ShonenJumpPlusAdapter() - work = adapter.normalize("https://shonenjumpplus.com/episode/17107419589191805801?from=episode") - client = StaticHttpClient( - { - "https://shonenjumpplus.com/episode/17107419589191805801": """ - - - [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+ - - - - - - - """, - "https://shonenjumpplus.com/rss/series/3269754496881854342": """ - - - 少年ジャンプ+(マリッジトキシン) - - [159話]マリッジトキシン - https://shonenjumpplus.com/episode/17107419589191805801 - マリッジトキシン - - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("https://shonenjumpplus.com/episode/17107419589191805801", latest["latestKey"]) - self.assertEqual("マリッジトキシン", latest["seriesTitle"]) - self.assertEqual("[159話]マリッジトキシン", latest["episodeTitle"]) - self.assertEqual( - [ - "https://shonenjumpplus.com/episode/17107419589191805801", - "https://shonenjumpplus.com/rss/series/3269754496881854342", - "https://shonenjumpplus.com/episode/17107419589191805801", - ], - client.calls, - ) - - def test_comicborder_normalize_accepts_episode_and_feed_urls(self): - episode_work = normalize_seed_url("https://comicborder.com/episode/12207421983437812169?from=share").to_dict() - rss_work = normalize_seed_url("https://comicborder.com/rss/series/12207421983437805229?from=share").to_dict() - atom_work = normalize_seed_url("https://comicborder.com/atom/series/12207421983437805229").to_dict() - - self.assertEqual( - { - "source": "comicborder", - "kind": "comicborder", - "workId": "https://comicborder.com/episode/12207421983437812169", - "seedUrl": "https://comicborder.com/episode/12207421983437812169", - }, - episode_work, - ) - - expected_feed = { - "source": "comicborder", - "kind": "comicborder", - "workId": "comicborder:12207421983437805229", - "seedUrl": "https://comicborder.com/rss/series/12207421983437805229", - "series": "comicborder:12207421983437805229", - "seriesId": "12207421983437805229", - "feedKind": "rss", - } - self.assertEqual(expected_feed, rss_work) - self.assertEqual(expected_feed, atom_work) - - def test_comic_earthstar_normalize_accepts_episode_and_feed_urls(self): - episode_work = normalize_seed_url( - "https://comic-earthstar.com/episode/12207421983526541742?from=share" - ).to_dict() - rss_work = normalize_seed_url( - "https://comic-earthstar.com/rss/series/12207421983526538413?from=share" - ).to_dict() - atom_work = normalize_seed_url( - "https://comic-earthstar.com/atom/series/12207421983526538413" - ).to_dict() - - self.assertEqual( - { - "source": "comic-earthstar", - "kind": "comic-earthstar", - "workId": "https://comic-earthstar.com/episode/12207421983526541742", - "seedUrl": "https://comic-earthstar.com/episode/12207421983526541742", - }, - episode_work, - ) - - expected_feed = { - "source": "comic-earthstar", - "kind": "comic-earthstar", - "workId": "comic-earthstar:12207421983526538413", - "seedUrl": "https://comic-earthstar.com/rss/series/12207421983526538413", - "series": "comic-earthstar:12207421983526538413", - "seriesId": "12207421983526538413", - "feedKind": "rss", - } - self.assertEqual(expected_feed, rss_work) - self.assertEqual(expected_feed, atom_work) - - def test_comic_earthstar_fetch_latest_accepts_canonical_feed_seed(self): - work = WorkDescriptor( - source="comic-earthstar", - work_id="comic-earthstar:12207421983526538413", - seed_url="https://comic-earthstar.com/rss/series/12207421983526538413", - metadata={ - "series": "comic-earthstar:12207421983526538413", - "seriesId": "12207421983526538413", - "feedKind": "rss", - }, - ) - client = StaticHttpClient( - { - "https://comic-earthstar.com/rss/series/12207421983526538413": """ - - - コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~) - - 第2話② - https://comic-earthstar.com/episode/12207421983562435589 - 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - - - - """, - "https://comic-earthstar.com/episode/12207421983562435589": """ - - - 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター - - - """, - } - ) - - latest = fetch_latest_for_work(work, http_client=client).to_dict() - - self.assertEqual("comic-earthstar:12207421983526538413", latest["workId"]) - self.assertEqual("comic-earthstar:12207421983526538413", latest["series"]) - self.assertEqual("https://comic-earthstar.com/episode/12207421983562435589", latest["latestKey"]) - self.assertEqual( - "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~", - latest["seriesTitle"], - ) - self.assertEqual("第2話②", latest["episodeTitle"]) - self.assertEqual( - [ - "https://comic-earthstar.com/rss/series/12207421983526538413", - "https://comic-earthstar.com/episode/12207421983562435589", - ], - client.calls, - ) - - def test_comic_earthstar_feed_parser_keeps_nested_parentheses_in_series_title_fallback(self): - latest = ComicEarthstarAdapter().fetch_latest( - WorkDescriptor( - source="comic-earthstar", - work_id="comic-earthstar:12207421983526538413", - seed_url="https://comic-earthstar.com/rss/series/12207421983526538413", - metadata={ - "series": "comic-earthstar:12207421983526538413", - "seriesId": "12207421983526538413", + cases = ( + ( + "https://championcross.jp/series/abc123/rss?ref=top", + { + "source": "champion-cross", + "kind": "champion-cross", + "workId": "champion-cross:abc123", + "seedUrl": "https://championcross.jp/series/abc123/rss", + "series": "champion-cross:abc123", + "seriesHash": "abc123", "feedKind": "rss", }, ), - StaticHttpClient( + ( + "https://championcross.jp/series/4756324e1c1b1/rss?from=share", { - "https://comic-earthstar.com/rss/series/12207421983526538413": """ - - - コミック アース・スター|毎週木曜・最新話更新!無料で漫画が読めるWEBコミック誌(魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~) - - 第2話② - https://comic-earthstar.com/episode/12207421983562435589 - - - - """, - "https://comic-earthstar.com/episode/12207421983562435589": """ - - - 第2話② / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター - - - """, - } + "source": "champion-cross", + "kind": "champion-cross", + "workId": "champion-cross:4756324e1c1b1", + "seedUrl": "https://championcross.jp/series/4756324e1c1b1/rss", + "series": "champion-cross:4756324e1c1b1", + "seriesHash": "4756324e1c1b1", + "feedKind": "rss", + }, ), - ).to_dict() - - self.assertEqual("https://comic-earthstar.com/episode/12207421983562435589", latest["latestKey"]) - self.assertEqual("第2話②", latest["episodeTitle"]) - self.assertEqual( - "魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~", - latest["seriesTitle"], ) - def test_comicborder_fetch_latest_accepts_canonical_feed_seed(self): - work = WorkDescriptor( - source="comicborder", - work_id="comicborder:12207421983437805229", - seed_url="https://comicborder.com/rss/series/12207421983437805229", - metadata={ - "series": "comicborder:12207421983437805229", - "seriesId": "12207421983437805229", - "feedKind": "rss", - }, - ) - client = StaticHttpClient( - { - "https://comicborder.com/rss/series/12207421983437805229": """ - - - コミックボーダー(マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道) - - 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ - https://comicborder.com/episode/12207421983437812169 - マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - - - - """, - "https://comicborder.com/episode/12207421983437812169": """ - - - マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー - - - """, - } - ) - - latest = fetch_latest_for_work(work, http_client=client).to_dict() + for query, expected in cases: + with self.subTest(query=query): + work = ChampionCrossAdapter().normalize(query) - self.assertEqual("comicborder:12207421983437805229", latest["workId"]) - self.assertEqual("comicborder:12207421983437805229", latest["series"]) - self.assertEqual("https://comicborder.com/episode/12207421983437812169", latest["latestKey"]) - self.assertEqual("マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道", latest["seriesTitle"]) - self.assertEqual("第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~", latest["episodeTitle"]) - self.assertEqual( - [ - "https://comicborder.com/rss/series/12207421983437805229", - "https://comicborder.com/episode/12207421983437812169", - ], - client.calls, - ) + self.assertEqual(expected, work.to_dict()) - def test_sunday_webry_normalize_accepts_episode_and_feed_urls(self): - episode_work = normalize_seed_url( - "https://www.sunday-webry.com/episode/12207421983581042977?from=share" - ).to_dict() - rss_work = normalize_seed_url( - "https://www.sunday-webry.com/rss/series/12207421983580960894?from=share" - ).to_dict() - atom_work = normalize_seed_url( - "https://www.sunday-webry.com/atom/series/12207421983580960894" - ).to_dict() + def test_champion_cross_normalize_accepts_episode_url(self): + work = ChampionCrossAdapter().normalize("https://championcross.jp/episodes/ep12345/?page=1") self.assertEqual( { - "source": "sunday-webry", - "kind": "sunday-webry", - "workId": "https://www.sunday-webry.com/episode/12207421983581042977", - "seedUrl": "https://www.sunday-webry.com/episode/12207421983581042977", + "source": "champion-cross", + "kind": "champion-cross", + "workId": "https://championcross.jp/episodes/ep12345", + "seedUrl": "https://championcross.jp/episodes/ep12345", }, - episode_work, + work.to_dict(), ) - expected_feed = { - "source": "sunday-webry", - "kind": "sunday-webry", - "workId": "sunday-webry:12207421983580960894", - "seedUrl": "https://www.sunday-webry.com/rss/series/12207421983580960894", - "series": "sunday-webry:12207421983580960894", - "seriesId": "12207421983580960894", - "feedKind": "rss", - } - self.assertEqual(expected_feed, rss_work) - self.assertEqual(expected_feed, atom_work) - - def test_kuragebunch_normalize_accepts_episode_and_feed_urls(self): - episode_work = normalize_seed_url("https://kuragebunch.com/episode/2550912964856491139?from=share").to_dict() - rss_work = normalize_seed_url("https://kuragebunch.com/rss/series/2550912964856487532?from=share").to_dict() - atom_work = normalize_seed_url("https://kuragebunch.com/atom/series/2550912964856487532").to_dict() + def test_comic_action_normalize_accepts_series_feed_url(self): + work = ComicActionAdapter().normalize("https://comic-action.com/rss/series/13933686331606207128?free_only=1") self.assertEqual( { - "source": "kuragebunch", - "kind": "kuragebunch", - "workId": "https://kuragebunch.com/episode/2550912964856491139", - "seedUrl": "https://kuragebunch.com/episode/2550912964856491139", + "source": "comic-action", + "kind": "comic-action", + "workId": "comic-action:13933686331606207128", + "seedUrl": "https://comic-action.com/rss/series/13933686331606207128", + "series": "comic-action:13933686331606207128", + "seriesId": "13933686331606207128", + "feedKind": "rss", }, - episode_work, + work.to_dict(), ) - expected_feed = { - "source": "kuragebunch", - "kind": "kuragebunch", - "workId": "kuragebunch:2550912964856487532", - "seedUrl": "https://kuragebunch.com/rss/series/2550912964856487532", - "series": "kuragebunch:2550912964856487532", - "seriesId": "2550912964856487532", - "feedKind": "rss", - } - self.assertEqual(expected_feed, rss_work) - self.assertEqual(expected_feed, atom_work) + def test_comic_action_fetch_latest_matrix(self): + self._assert_fetch_cases( + ( + { + "source": "comic-action", + "case": "series_feed_seed", + "seed_url": "https://comic-action.com/atom/series/13933686331606207128", + "responses": { + "https://comic-action.com/atom/series/13933686331606207128": """ + + + + + + """, + "https://comic-action.com/episode/11341664176570134078": """ + + + 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション + + + + """, + }, + "expected_latest": { + "workId": "comic-action:13933686331606207128", + "latestKey": "https://comic-action.com/episode/11341664176570134078", + "seriesTitle": "つぐもも", + "episodeTitle": "第1話 母さんの形見", + }, + "expected_calls": [ + "https://comic-action.com/atom/series/13933686331606207128", + "https://comic-action.com/episode/11341664176570134078", + ], + }, + { + "source": "comic-action", + "case": "next_update_label_from_latest_page", + "seed_url": "https://comic-action.com/episode/111", + "responses": { + "https://comic-action.com/episode/111": """ + + 第1話 / 作品B - webアクション | comic-action + + + + + """, + "https://comic-action.com/episode/222": """ + + 第2話 / 作品B - webアクション | comic-action + +
+

次回更新: 4月3日

+
+ + + """, + }, + "expected_latest": { + "nextUpdateLabel": "4月3日", + }, + }, + { + "source": "comic-action", + "case": "series_rss_fallback_when_readable_chain_stops", + "seed_url": "https://comic-action.com/episode/2550689798784879524", + "responses": { + "https://comic-action.com/episode/2550689798784879524": """ + + 第39話 / ダンジョンの中のひと - 双見酔 | webアクション + + + + + + """, + "https://comic-action.com/rss/series/13933686331663374228": """ + + + + 第51話 + https://comic-action.com/episode/2551460910007760899 + + + 第50話 + https://comic-action.com/episode/2551460909780695609 + + + + """, + "https://comic-action.com/episode/2551460910007760899": """ + + 第51話 / ダンジョンの中のひと - 双見酔 | webアクション + + +
+

次回更新: 4月4日

+
+ + + """, + }, + "expected_latest": { + "latestKey": "https://comic-action.com/episode/2551460910007760899", + "seriesTitle": "ダンジョンの中のひと", + "episodeTitle": "第51話", + "nextUpdateLabel": "4月4日", + }, + "expected_calls": [ + "https://comic-action.com/episode/2550689798784879524", + "https://comic-action.com/rss/series/13933686331663374228", + "https://comic-action.com/episode/2551460910007760899", + ], + }, + { + "source": "comic-action", + "case": "entry_episode_when_series_rss_fetch_fails", + "seed_url": "https://comic-action.com/episode/2550689798784879524", + "responses": { + "https://comic-action.com/episode/2550689798784879524": """ + + 第39話 / ダンジョンの中のひと - 双見酔 | webアクション + + + + + + """, + "https://comic-action.com/rss/series/13933686331663374228": RuntimeError( + "temporary feed outage" + ), + }, + "expected_latest": { + "latestKey": "https://comic-action.com/episode/2550689798784879524", + "seriesTitle": "ダンジョンの中のひと", + "episodeTitle": "第39話", + }, + "expected_calls": [ + "https://comic-action.com/episode/2550689798784879524", + "https://comic-action.com/rss/series/13933686331663374228", + ], + }, + { + "source": "comic-action", + "case": "entry_episode_when_series_rss_is_stale", + "seed_url": "https://comic-action.com/episode/2550689798784879524", + "responses": { + "https://comic-action.com/episode/2550689798784879524": """ + + 第39話 / ダンジョンの中のひと - 双見酔 | webアクション + + + + + + """, + "https://comic-action.com/rss/series/13933686331663374228": """ + + + + 第50話 + https://comic-action.com/episode/2551460909780695609 + + + + """, + "https://comic-action.com/episode/2551460909780695609": """ + + 第50話 / ダンジョンの中のひと - 双見酔 | webアクション + + + + + """, + }, + "expected_latest": { + "latestKey": "https://comic-action.com/episode/2550689798784879524", + "seriesTitle": "ダンジョンの中のひと", + "episodeTitle": "第39話", + }, + "expected_calls": [ + "https://comic-action.com/episode/2550689798784879524", + "https://comic-action.com/rss/series/13933686331663374228", + "https://comic-action.com/episode/2551460909780695609", + ], + }, + { + "source": "comic-action", + "case": "www_episode_links_from_feed", + "seed_url": "https://comic-action.com/rss/series/13933686331606207128", + "responses": { + "https://comic-action.com/rss/series/13933686331606207128": """ + + + + https://www.comic-action.com/episode/11341664176570134078 + + + + """, + "https://comic-action.com/episode/11341664176570134078": """ + + + 第1話 母さんの形見 / つぐもも - 浜田よしかづ | webアクション + + + + """, + }, + "expected_latest": { + "workId": "comic-action:13933686331606207128", + "latestKey": "https://comic-action.com/episode/11341664176570134078", + }, + "expected_calls": [ + "https://comic-action.com/rss/series/13933686331606207128", + "https://comic-action.com/episode/11341664176570134078", + ], + }, + ), + ) - def test_sunday_webry_fetch_latest_accepts_episode_seed(self): - adapter = SundayWebryAdapter() - work = adapter.normalize("https://www.sunday-webry.com/episode/12207421983581042977?from=episode") + def test_comic_walker_fetch_latest_extracts_next_update_label(self): + adapter = ComicWalkerAdapter() + work = adapter.normalize("https://comic-walker.com/detail/KC_123456_S") client = StaticHttpClient( { - "https://www.sunday-webry.com/episode/12207421983581042977": """ + "https://comic-walker.com/detail/KC_123456_S": """ - - diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり - - - - + """, - "https://www.sunday-webry.com/rss/series/12207421983580960894": """ - - - サンデーうぇぶり(しっぽと逆鱗) - - diary1 つきこと先生 - https://www.sunday-webry.com/episode/12207421983581042977 - しっぽと逆鱗 - - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("sunday-webry:12207421983580960894", latest["workId"]) - self.assertEqual("sunday-webry:12207421983580960894", latest["series"]) - self.assertEqual("https://www.sunday-webry.com/episode/12207421983581042977", latest["latestKey"]) - self.assertEqual("しっぽと逆鱗", latest["seriesTitle"]) - self.assertEqual("diary1 つきこと先生", latest["episodeTitle"]) - self.assertEqual( - "diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり", - latest["pageTitle"], - ) - self.assertEqual( - [ - "https://www.sunday-webry.com/episode/12207421983581042977", - "https://www.sunday-webry.com/rss/series/12207421983580960894", - ], - client.calls, - ) - - def test_sunday_webry_fetch_latest_uses_latest_page_title_when_episode_seed_is_stale(self): - adapter = SundayWebryAdapter() - work = adapter.normalize("https://www.sunday-webry.com/episode/12207421983581042977?from=episode") - client = StaticHttpClient( - { - "https://www.sunday-webry.com/episode/12207421983581042977": """ + "https://comic-walker.com/detail/KC_123456_S/episodes/KC_123456003_E?episodeType=latest": """ - - diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり - - + 【作品A】第3話|カドコミ (コミックウォーカー) - +
次回更新予定日:未定
""", - "https://www.sunday-webry.com/rss/series/12207421983580960894": """ - - - サンデーうぇぶり(しっぽと逆鱗) - - - https://www.sunday-webry.com/episode/12207421983581043001 - - - - - """, - "https://www.sunday-webry.com/episode/12207421983581043001": """ - - - diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり - - - - """, } ) latest = adapter.fetch_latest(work, client).to_dict() - self.assertEqual("https://www.sunday-webry.com/episode/12207421983581043001", latest["latestKey"]) - self.assertEqual("diary2 新しい話", latest["episodeTitle"]) - self.assertEqual("しっぽと逆鱗", latest["seriesTitle"]) - self.assertEqual( - "diary2 新しい話 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり", - latest["pageTitle"], - ) - self.assertEqual( - [ - "https://www.sunday-webry.com/episode/12207421983581042977", - "https://www.sunday-webry.com/rss/series/12207421983580960894", - "https://www.sunday-webry.com/episode/12207421983581043001", - ], - client.calls, - ) + self.assertEqual("未定", latest["nextUpdateLabel"]) - def test_kuragebunch_fetch_latest_accepts_canonical_feed_seed(self): - work = WorkDescriptor( - source="kuragebunch", - work_id="kuragebunch:2550912964856487532", - seed_url="https://kuragebunch.com/rss/series/2550912964856487532", - metadata={ - "series": "kuragebunch:2550912964856487532", - "seriesId": "2550912964856487532", - "feedKind": "rss", - }, - ) - client = StaticHttpClient( - { - "https://kuragebunch.com/rss/series/2550912964856487532": """ - - - くらげバンチ(赤と青のガウン) - - 第15話 - https://kuragebunch.com/episode/12207421983430264919 - 赤と青のガウン - - - - """, - "https://kuragebunch.com/episode/12207421983430264919": """ - - - 赤と青のガウン - 彬子女王/池辺葵 / 第15話 | くらげバンチ - - - """, - } + def test_kakuyomu_fetch_latest_matrix(self): + self._assert_fetch_cases( + ( + { + "source": "kakuyomu", + "case": "next_update_label_from_schedule", + "seed_url": "https://kakuyomu.jp/works/123", + "responses": { + "https://kakuyomu.jp/works/123": """ + + + + + + """, + "https://kakuyomu.jp/works/123/episodes/789": """ + + 第2話 - 作品C - カクヨム + + """, + }, + "expected_latest": { + "nextUpdateLabel": "毎日 12:08", + }, + }, + { + "source": "kakuyomu", + "case": "null_schedule", + "seed_url": "https://kakuyomu.jp/works/123", + "responses": { + "https://kakuyomu.jp/works/123": """ + + + + + + """, + "https://kakuyomu.jp/works/123/episodes/789": """ + + 第2話 - 作品C - カクヨム + + """, + }, + "missing_latest_keys": ("nextUpdateLabel",), + }, + ), ) - latest = fetch_latest_for_work(work, http_client=client).to_dict() - - self.assertEqual("kuragebunch:2550912964856487532", latest["workId"]) - self.assertEqual("kuragebunch:2550912964856487532", latest["series"]) - self.assertEqual("https://kuragebunch.com/episode/12207421983430264919", latest["latestKey"]) - self.assertEqual("赤と青のガウン", latest["seriesTitle"]) - self.assertEqual("第15話", latest["episodeTitle"]) - self.assertEqual( - [ - "https://kuragebunch.com/rss/series/2550912964856487532", - "https://kuragebunch.com/episode/12207421983430264919", - ], - client.calls, - ) + def test_shonenjumpplus_normalize_accepts_series_feed_urls(self): + adapter = ShonenJumpPlusAdapter() - def test_champion_cross_normalize_accepts_series_rss_url(self): - work = ChampionCrossAdapter().normalize("https://championcross.jp/series/4756324e1c1b1/rss?from=share") + rss_work = adapter.normalize("https://shonenjumpplus.com/rss/series/3269754496881854342?from=share") + atom_work = adapter.normalize("https://shonenjumpplus.com/atom/series/3269754496881854342") - self.assertEqual( - { - "source": "champion-cross", - "kind": "champion-cross", - "workId": "champion-cross:4756324e1c1b1", - "seedUrl": "https://championcross.jp/series/4756324e1c1b1/rss", - "series": "champion-cross:4756324e1c1b1", - "seriesHash": "4756324e1c1b1", - "feedKind": "rss", - }, - work.to_dict(), - ) + expected = { + "source": "shonenjumpplus", + "kind": "shonenjumpplus", + "workId": "shonenjumpplus:3269754496881854342", + "seedUrl": "https://shonenjumpplus.com/rss/series/3269754496881854342", + "series": "shonenjumpplus:3269754496881854342", + "seriesId": "3269754496881854342", + "feedKind": "rss", + } + self.assertEqual(expected, rss_work.to_dict()) + self.assertEqual(expected, atom_work.to_dict()) def test_champion_cross_fetch_latest_accepts_series_url(self): adapter = ChampionCrossAdapter() @@ -1820,116 +1608,109 @@ def test_takecomic_fetch_latest_accepts_series_url(self): client.calls, ) - def test_firecross_fetch_latest_accepts_reader_url(self): - adapter = ADAPTERS["firecross"]() - work = adapter.normalize("https://firecross.jp/reader/19386?trial=0&token=temp") - client = StaticHttpClient( - { - "https://firecross.jp/reader/19386": """ - - 第12話 / 作品E | ファイアCROSS - - 作品詳細 - - - """, - "https://firecross.jp/series/series-abc": """ - - 作品E | ファイアCROSS - - 最新話 - - - """, - "https://firecross.jp/reader/19420": """ - - 第13話 / 作品E | ファイアCROSS - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("https://firecross.jp/reader/19420", latest["latestKey"]) - self.assertEqual("https://firecross.jp/reader/19420", latest["url"]) - self.assertEqual("firecross:series-abc", latest["series"]) - self.assertEqual("作品E", latest["seriesTitle"]) - self.assertEqual("第13話", latest["episodeTitle"]) - self.assertEqual( - [ - "https://firecross.jp/reader/19386", - "https://firecross.jp/series/series-abc", - "https://firecross.jp/reader/19420", - ], - client.calls, - ) - - def test_firecross_fetch_latest_requires_explicit_latest_signal(self): - adapter = ADAPTERS["firecross"]() - work = adapter.normalize("https://firecross.jp/reader/19386?trial=0&token=temp") - client = StaticHttpClient( - { - "https://firecross.jp/reader/19386": """ - - 第12話 / 作品E | ファイアCROSS - - 作品詳細 - - - """, - "https://firecross.jp/series/series-abc": """ - - 作品E | ファイアCROSS - - 第10話 - 第13話 - - - """, - } - ) - - with self.assertRaisesRegex(SourceParseError, "firecross: latest reader URL not found"): - adapter.fetch_latest(work, client) - - def test_firecross_fetch_latest_accepts_ebook_series_url(self): - adapter = ADAPTERS["firecross"]() - work = adapter.normalize("https://firecross.jp/ebook/series/358?sort=latest") - client = StaticHttpClient( - { - "https://firecross.jp/ebook/series/358?sort=latest": """ - - 作品E | ファイアCROSS - - 第13話 - 第10話 - - - """, - "https://firecross.jp/reader/19420": """ - - 第13話 / 作品E | ファイアCROSS - - - """, - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("https://firecross.jp/reader/19420", latest["latestKey"]) - self.assertEqual("https://firecross.jp/reader/19420", latest["url"]) - self.assertEqual("firecross:358", latest["series"]) - self.assertEqual("作品E", latest["seriesTitle"]) - self.assertEqual("第13話", latest["episodeTitle"]) - self.assertEqual( - [ - "https://firecross.jp/ebook/series/358?sort=latest", - "https://firecross.jp/reader/19420", - ], - client.calls, + def test_firecross_fetch_latest_matrix(self): + self._assert_fetch_cases( + ( + { + "source": "firecross", + "case": "reader_seed", + "seed_url": "https://firecross.jp/reader/19386?trial=0&token=temp", + "responses": { + "https://firecross.jp/reader/19386": """ + + 第12話 / 作品E | ファイアCROSS + + 作品詳細 + + + """, + "https://firecross.jp/series/series-abc": """ + + 作品E | ファイアCROSS + + 最新話 + + + """, + "https://firecross.jp/reader/19420": """ + + 第13話 / 作品E | ファイアCROSS + + + """, + }, + "expected_latest": { + "latestKey": "https://firecross.jp/reader/19420", + "url": "https://firecross.jp/reader/19420", + "series": "firecross:series-abc", + "seriesTitle": "作品E", + "episodeTitle": "第13話", + }, + "expected_calls": [ + "https://firecross.jp/reader/19386", + "https://firecross.jp/series/series-abc", + "https://firecross.jp/reader/19420", + ], + }, + { + "source": "firecross", + "case": "requires_explicit_latest_signal", + "seed_url": "https://firecross.jp/reader/19386?trial=0&token=temp", + "responses": { + "https://firecross.jp/reader/19386": """ + + 第12話 / 作品E | ファイアCROSS + + 作品詳細 + + + """, + "https://firecross.jp/series/series-abc": """ + + 作品E | ファイアCROSS + + 第10話 + 第13話 + + + """, + }, + "expected_error": "firecross: latest reader URL not found", + }, + { + "source": "firecross", + "case": "ebook_series_seed", + "seed_url": "https://firecross.jp/ebook/series/358?sort=latest", + "responses": { + "https://firecross.jp/ebook/series/358?sort=latest": """ + + 作品E | ファイアCROSS + + 第13話 + 第10話 + + + """, + "https://firecross.jp/reader/19420": """ + + 第13話 / 作品E | ファイアCROSS + + + """, + }, + "expected_latest": { + "latestKey": "https://firecross.jp/reader/19420", + "url": "https://firecross.jp/reader/19420", + "series": "firecross:358", + "seriesTitle": "作品E", + "episodeTitle": "第13話", + }, + "expected_calls": [ + "https://firecross.jp/ebook/series/358?sort=latest", + "https://firecross.jp/reader/19420", + ], + }, + ), ) def test_nicovideo_manga_fetch_latest_accepts_comic_url(self): @@ -2069,79 +1850,78 @@ def test_bookwalker_normalize_accepts_series_and_product_urls(self): adapter.normalize("https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/?sample=1").to_dict(), ) - def test_bookwalker_fetch_latest_prefers_latest_series_card(self): - adapter = BookwalkerAdapter() - work = adapter.normalize("https://bookwalker.jp/series/519222/list/") - client = StaticHttpClient( - { - "https://bookwalker.jp/series/519222/list/": """ - - くまぐらし(MANGAバル コミックス)一覧 - BOOK☆WALKER - - - - - - """ - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("bookwalker:series:519222", latest["workId"]) - self.assertEqual("bookwalker:series:519222", latest["series"]) - self.assertEqual("bookwalker:book:893cb2ba-dc87-4c1b-90cb-a1cd13d33a0f", latest["latestKey"]) - self.assertEqual("くまぐらし(MANGAバル コミックス)", latest["seriesTitle"]) - self.assertEqual("くまぐらし (06)", latest["episodeTitle"]) - self.assertEqual("2026/5/14(木) 配信予定", latest["nextUpdateLabel"]) - self.assertEqual(["https://bookwalker.jp/series/519222/list/"], client.calls) - - def test_bookwalker_fetch_latest_reads_episode_titles_from_wauri_series(self): - adapter = BookwalkerAdapter() - work = adapter.normalize("https://bookwalker.jp/series/559081/") - client = StaticHttpClient( - { - "https://bookwalker.jp/series/559081/": """ - - 【話・連載】救世のアルル【分冊版】(ノヴァコミックス) - BOOK☆WALKER - -

2026/3/25(水) 更新

- -

1

-
- -

8

-
- - - """ - } - ) - - latest = adapter.fetch_latest(work, client).to_dict() - - self.assertEqual("bookwalker:series:559081", latest["workId"]) - self.assertEqual("bookwalker:book:b5c239d6-7f48-4d79-b2ee-c0fb0e481a35", latest["latestKey"]) - self.assertEqual("救世のアルル【分冊版】(ノヴァコミックス)", latest["seriesTitle"]) - self.assertEqual("救世のアルル【分冊版】8", latest["episodeTitle"]) - self.assertEqual( - "https://bookwalker.jp/deb5c239d6-7f48-4d79-b2ee-c0fb0e481a35/", - latest["url"], + def test_bookwalker_fetch_latest_matrix(self): + self._assert_fetch_cases( + ( + { + "source": "bookwalker", + "case": "latest_series_card", + "seed_url": "https://bookwalker.jp/series/519222/list/", + "responses": { + "https://bookwalker.jp/series/519222/list/": """ + + くまぐらし(MANGAバル コミックス)一覧 - BOOK☆WALKER + + + + + + """ + }, + "expected_latest": { + "workId": "bookwalker:series:519222", + "series": "bookwalker:series:519222", + "latestKey": "bookwalker:book:893cb2ba-dc87-4c1b-90cb-a1cd13d33a0f", + "seriesTitle": "くまぐらし(MANGAバル コミックス)", + "episodeTitle": "くまぐらし (06)", + "nextUpdateLabel": "2026/5/14(木) 配信予定", + }, + "expected_calls": ["https://bookwalker.jp/series/519222/list/"], + }, + { + "source": "bookwalker", + "case": "wauri_series_episode_titles", + "seed_url": "https://bookwalker.jp/series/559081/", + "responses": { + "https://bookwalker.jp/series/559081/": """ + + 【話・連載】救世のアルル【分冊版】(ノヴァコミックス) - BOOK☆WALKER + +

2026/3/25(水) 更新

+ +

1

+
+ +

8

+
+ + + """ + }, + "expected_latest": { + "workId": "bookwalker:series:559081", + "latestKey": "bookwalker:book:b5c239d6-7f48-4d79-b2ee-c0fb0e481a35", + "seriesTitle": "救世のアルル【分冊版】(ノヴァコミックス)", + "episodeTitle": "救世のアルル【分冊版】8", + "url": "https://bookwalker.jp/deb5c239d6-7f48-4d79-b2ee-c0fb0e481a35/", + "nextUpdateLabel": "2026/3/25(水) 更新", + }, + }, + ), ) - self.assertEqual("2026/3/25(水) 更新", latest["nextUpdateLabel"]) def _assert_fixture_matrix(self, source: str): source_dir = FIXTURES_ROOT / source diff --git a/tests/test_storage.py b/tests/test_storage.py index ed46ed0..872326b 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -226,66 +226,42 @@ def writer(worker_id: int): self.assertIn(final_latest_key, written_keys) self.assertEqual(final_latest_key, json.loads(state_path.read_text(encoding="utf-8"))["works"]["work-1"]["latest"]["latest_key"]) - def test_supertwins_search_sessions_round_trip_without_overwrite(self): - with tempfile.TemporaryDirectory() as tmpdir: - state_path = Path(tmpdir) / "state.json" - save_supertwins_search_session( - "session-a", - {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}}, - path=str(state_path), - ) - save_supertwins_search_session( - "session-b", - {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}}, - path=str(state_path), - ) - - session_a = load_supertwins_search_session("session-a", str(state_path)) - session_b = load_supertwins_search_session("session-b", str(state_path)) - delete_supertwins_search_session("session-a", str(state_path)) - - self.assertEqual( - {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}}, - session_a, - ) - self.assertEqual( - {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}}, - session_b, - ) - with self.assertRaises(FileNotFoundError): - load_supertwins_search_session("session-a", str(state_path)) - - def test_where_sessions_round_trip_without_overwrite(self): - with tempfile.TemporaryDirectory() as tmpdir: - state_path = Path(tmpdir) / "state.json" - save_where_session( - "session-a", - {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]}, - path=str(state_path), - ) - save_where_session( - "session-b", - {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]}, - path=str(state_path), - ) - - session_a = load_where_session("session-a", str(state_path)) - session_b = load_where_session("session-b", str(state_path)) - delete_where_session("session-a", str(state_path)) - - self.assertEqual( - {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]}, - session_a, - ) - self.assertEqual( - {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]}, - session_b, - ) - with self.assertRaises(FileNotFoundError): - load_where_session("session-a", str(state_path)) - - def test_validate_watchlist_normalizes_hidden_to_boolean(self): - normalized = validate_watchlist( + def test_search_sessions_round_trip_without_overwrite(self): + session_cases = { + "supertwins": { + "save": save_supertwins_search_session, + "load": load_supertwins_search_session, + "delete": delete_supertwins_search_session, + "payload_a": {"root_work_id": "root-1", "selected_urls_by_value": {"u:a": "https://example.com/a"}}, + "payload_b": {"root_work_id": "root-2", "selected_urls_by_value": {"u:b": "https://example.com/b"}}, + }, + "where": { + "save": save_where_session, + "load": load_where_session, + "delete": delete_where_session, + "payload_a": {"query": "作品A", "episode": "1話", "results": [{"source": "comic-walker"}]}, + "payload_b": {"query": "作品B", "episode": "2話", "results": [{"source": "nicovideo-manga"}]}, + }, + } + for session_kind, funcs in session_cases.items(): + with self.subTest(session_kind=session_kind): + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + funcs["save"]("session-a", funcs["payload_a"], path=str(state_path)) + funcs["save"]("session-b", funcs["payload_b"], path=str(state_path)) + + session_a = funcs["load"]("session-a", str(state_path)) + session_b = funcs["load"]("session-b", str(state_path)) + funcs["delete"]("session-a", str(state_path)) + + self.assertEqual(funcs["payload_a"], session_a) + self.assertEqual(funcs["payload_b"], session_b) + with self.assertRaises(FileNotFoundError): + funcs["load"]("session-a", str(state_path)) + + def test_validate_watchlist_hidden_boolean_matrix(self): + with self.subTest(case="missing_and_true_normalize_to_boolean"): + normalized = validate_watchlist( { "version": 2, "works": [ @@ -311,23 +287,23 @@ def test_validate_watchlist_normalizes_hidden_to_boolean(self): self.assertFalse(normalized["works"][0]["hidden"]) self.assertTrue(normalized["works"][1]["hidden"]) - def test_validate_watchlist_rejects_non_boolean_hidden(self): - with self.assertRaisesRegex(ValueError, "watchlist entry work-1 hidden must be boolean"): - validate_watchlist( - { - "version": 2, - "works": [ - { - "id": "work-1", - "source": "comic-walker", - "seed_url": "https://example.com/work-1", - "enabled": True, - "hidden": "yes", - "notification_policy": {"mode": "all", "allowed_update_types": None}, - } - ], - } - ) + with self.subTest(case="non_boolean_hidden_rejected"): + with self.assertRaisesRegex(ValueError, "watchlist entry work-1 hidden must be boolean"): + validate_watchlist( + { + "version": 2, + "works": [ + { + "id": "work-1", + "source": "comic-walker", + "seed_url": "https://example.com/work-1", + "enabled": True, + "hidden": "yes", + "notification_policy": {"mode": "all", "allowed_update_types": None}, + } + ], + } + ) if __name__ == "__main__": diff --git a/tests/test_watchlist.py b/tests/test_watchlist.py index 73382ec..b46ace7 100644 --- a/tests/test_watchlist.py +++ b/tests/test_watchlist.py @@ -198,409 +198,6 @@ def test_add_watchlist_url_accepts_comic_trail_episode_url_and_canonicalizes_to_ self.assertEqual(1, payload["work_count"]) self.assertEqual(1, len(saved["works"])) - def test_add_watchlist_url_accepts_comic_days_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://comic-days.com/episode/12207421983746014850?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://comic-days.com/episode/12207421983746014850": """ - - - メダリスト - つるまいかだ / score60 牧師と子羊① | コミックDAYS - - - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("comic-days:13933686331650127004", payload["entry"]["id"]) - self.assertEqual( - "https://comic-days.com/rss/series/13933686331650127004", - payload["entry"]["seed_url"], - ) - self.assertEqual("comic-days", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_magapoke_episode_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://pocket.shonenmagazine.com/title/03021/episode/427856?utm_source=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://pocket.shonenmagazine.com/title/03021": """ - - - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("magapoke:3021", payload["entry"]["id"]) - self.assertEqual( - "https://pocket.shonenmagazine.com/title/03021", - payload["entry"]["seed_url"], - ) - self.assertEqual("magapoke", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_takecomic_series_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://takecomic.jp/series/3f846451aff2d/?utm_source=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient({}), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("takecomic:3f846451aff2d", payload["entry"]["id"]) - self.assertEqual( - "https://takecomic.jp/series/3f846451aff2d", - payload["entry"]["seed_url"], - ) - self.assertEqual("takecomic", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_shonenjumpplus_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://shonenjumpplus.com/episode/17107419589191805801?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://shonenjumpplus.com/episode/17107419589191805801": """ - - - [159話]マリッジトキシン - 静脈/依田瑞稀 | 少年ジャンプ+ - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("shonenjumpplus:3269754496881854342", payload["entry"]["id"]) - self.assertEqual( - "https://shonenjumpplus.com/rss/series/3269754496881854342", - payload["entry"]["seed_url"], - ) - self.assertEqual("shonenjumpplus", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_comicborder_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://comicborder.com/episode/12207421983437812169?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://comicborder.com/episode/12207421983437812169": """ - - - マヨネーズ王は貧乏になりたい!【男女比1:100】世界で逝く勘違い出世街道 - 神影龍之介/馬路まんじ / 第01話 死んでサイタマ ~異世界全方位成り上がりRTA開始(※望んでない)~ | コミックボーダー - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("comicborder:12207421983437805229", payload["entry"]["id"]) - self.assertEqual( - "https://comicborder.com/rss/series/12207421983437805229", - payload["entry"]["seed_url"], - ) - self.assertEqual("comicborder", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_comic_earthstar_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://comic-earthstar.com/episode/12207421983526541742?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://comic-earthstar.com/episode/12207421983526541742": """ - - - 第1話 / 魔物(マンドラゴラ)ってバレたら討伐ですか? ~花の魔女のほほえみは勘違いの種を蒔く~ - 漫画:大林ポチ子/原作:Mikura/キャラクター原案:中西達哉 | コミック アース・スター - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("comic-earthstar:12207421983526538413", payload["entry"]["id"]) - self.assertEqual( - "https://comic-earthstar.com/rss/series/12207421983526538413", - payload["entry"]["seed_url"], - ) - self.assertEqual("comic-earthstar", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_sunday_webry_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://www.sunday-webry.com/episode/12207421983581042977?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://www.sunday-webry.com/episode/12207421983581042977": """ - - - diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり - - - - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("sunday-webry:12207421983580960894", payload["entry"]["id"]) - self.assertEqual( - "https://www.sunday-webry.com/rss/series/12207421983580960894", - payload["entry"]["seed_url"], - ) - self.assertEqual("sunday-webry", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_kuragebunch_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://kuragebunch.com/episode/2550912964856491139?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://kuragebunch.com/episode/2550912964856491139": """ - - - 赤と青のガウン - 彬子女王/池辺葵 / 第1話 | くらげバンチ - - - -
- - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("kuragebunch:2550912964856487532", payload["entry"]["id"]) - self.assertEqual( - "https://kuragebunch.com/rss/series/2550912964856487532", - payload["entry"]["seed_url"], - ) - self.assertEqual("kuragebunch", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_sunday_webry_apex_episode_url_and_canonicalizes_to_rss(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://sunday-webry.com/episode/12207421983581042977?from=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://www.sunday-webry.com/episode/12207421983581042977": """ - - - diary1 つきこと先生 / しっぽと逆鱗 - 由田果 | サンデーうぇぶり - - - - - - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("sunday-webry:12207421983580960894", payload["entry"]["id"]) - self.assertEqual( - "https://www.sunday-webry.com/rss/series/12207421983580960894", - payload["entry"]["seed_url"], - ) - self.assertEqual("sunday-webry", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_firecross_reader_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://firecross.jp/reader/19386?trial=0&token=temp&vertical=0", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient( - { - "https://firecross.jp/reader/19386": """ - - - 作品詳細 - - - """ - } - ), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("firecross:series-abc", payload["entry"]["id"]) - self.assertEqual( - "https://firecross.jp/reader/19386", - payload["entry"]["seed_url"], - ) - self.assertEqual("firecross", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_add_watchlist_url_accepts_firecross_ebook_series_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://firecross.jp/ebook/series/358?sort=latest", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient({}), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("firecross:358", payload["entry"]["id"]) - self.assertEqual( - "https://firecross.jp/ebook/series/358", - payload["entry"]["seed_url"], - ) - self.assertEqual("firecross", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_watchlist_add_accepts_nicovideo_sp_comic_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://sp.manga.nicovideo.jp/comic/53764?track=share", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient({}), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("nicovideo-manga:53764", payload["entry"]["id"]) - self.assertEqual( - "https://manga.nicovideo.jp/comic/53764", - payload["entry"]["seed_url"], - ) - self.assertEqual("nicovideo-manga", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_watchlist_add_accepts_gaugau_work_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000?from=search", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient({}), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual("gaugau:600a5fd37765610d30010000", payload["entry"]["id"]) - self.assertEqual( - "https://gaugau.futabanet.jp/list/work/600a5fd37765610d30010000", - payload["entry"]["seed_url"], - ) - self.assertEqual("gaugau", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - def test_watchlist_add_accepts_piccoma_product_url(self): with tempfile.TemporaryDirectory() as tmpdir: watchlist_path = Path(tmpdir) / "watchlist.json" @@ -687,36 +284,6 @@ def test_watchlist_add_accepts_bookwalker_series_url(self): self.assertEqual(1, payload["work_count"]) self.assertEqual(1, len(saved["works"])) - def test_watchlist_add_accepts_bookwalker_book_detail_url(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - - payload = add_watchlist_url( - "https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/?sample=1", - watchlist_path=str(watchlist_path), - http_client=StaticHttpClient({}), - ) - saved = json.loads(watchlist_path.read_text(encoding="utf-8")) - - self.assertEqual("added", payload["action"]) - self.assertEqual( - "bookwalker:book:2b0ecd13-4ad0-405e-9fe9-a625ad80c74c", - payload["entry"]["id"], - ) - self.assertEqual( - "https://bookwalker.jp/de2b0ecd13-4ad0-405e-9fe9-a625ad80c74c/", - payload["entry"]["seed_url"], - ) - self.assertEqual("bookwalker", payload["entry"]["source"]) - self.assertEqual(1, payload["work_count"]) - self.assertEqual(1, len(saved["works"])) - - def test_watchlist_add_reports_unsupported_source(self): - with tempfile.TemporaryDirectory() as tmpdir: - watchlist_path = Path(tmpdir) / "watchlist.json" - write_watchlist(watchlist_path, []) - class WatchlistCliRetirementTests(unittest.TestCase): maxDiff = None diff --git a/tests/test_web_admin_settings.py b/tests/test_web_admin_settings.py deleted file mode 100644 index 619467d..0000000 --- a/tests/test_web_admin_settings.py +++ /dev/null @@ -1 +0,0 @@ -"""Reserved for future web admin settings tests.""" From 371c08316bb6779b6dd4552c4d00593af80840c4 Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 12 Sep 2026 19:39:17 +0900 Subject: [PATCH 2/3] fix: point traceability manifest at renamed matrix tests --- tests/acceptance_traceability.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/acceptance_traceability.py b/tests/acceptance_traceability.py index 8ace6cc..d4e0bb4 100644 --- a/tests/acceptance_traceability.py +++ b/tests/acceptance_traceability.py @@ -10,68 +10,68 @@ "TC-LATEST-02": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-03": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-04": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-05": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched", - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_includes_unfetched_rows_when_mixed_with_saved_results", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-06": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_returns_empty_message_when_all_works_are_unfetched", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-07": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_watchlist_order_and_ignores_disabled_and_orphans", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-08": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-09": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_uses_plain_text_and_fallback_labels_without_url", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-10": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_episode_label_truncation_matches_spec_examples", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-11": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_episode_label_truncation_matches_spec_examples", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-12": { "layer": "formatter", "tests": [ - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_warns_for_stale_saved_data", + "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-DAILY-01": { @@ -91,7 +91,7 @@ "TC-DAILY-03": { "layer": "orchestration", "tests": [ - "tests.test_discord_outbound.DiscordOutboundTests.test_enqueue_daily_notification_dedupes_by_work_id_and_latest_key_even_if_metadata_changes", + "tests.test_discord_outbound.DiscordOutboundTests.test_enqueue_daily_notification_dedupe_matrix", ], }, "TC-DAILY-04": { @@ -238,8 +238,8 @@ "TC-SEC-01": { "layer": "security", "tests": [ - "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_in_transport_error", - "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_in_error_response", + "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_matrix", + "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_matrix", "tests.test_runner.RunnerTests.test_run_once_redacts_secrets_from_failure_outputs", ], }, From 6d650004d32a1bf5472fd6c9afa0cc3d7d7b7c4d Mon Sep 17 00:00:00 2001 From: "kentoku.matsunami" Date: Sat, 12 Sep 2026 19:57:27 +0900 Subject: [PATCH 3/3] fix: address review nits (manifest dedupe, comic-days matrix, BOM strip, yaml self-test, manifest guard, repair backup) --- manga_watch/repair_mojibake.py | 10 +++- manga_watch/sources/base.py | 23 +++++++-- tests/acceptance_traceability.py | 2 - tests/test_acceptance_manifest.py | 37 ++++++++++++++ tests/test_check.py | 8 ++++ tests/test_github_workflows.py | 48 +++++++++++++++++++ tests/test_repair_mojibake.py | 80 +++++++++++++++++++++++++++++++ tests/test_sources.py | 5 +- 8 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 tests/test_acceptance_manifest.py diff --git a/manga_watch/repair_mojibake.py b/manga_watch/repair_mojibake.py index 9869f12..4f09e09 100644 --- a/manga_watch/repair_mojibake.py +++ b/manga_watch/repair_mojibake.py @@ -13,6 +13,7 @@ import argparse import json import os +import shutil import sys from typing import Dict, List, Optional, TextIO, Tuple @@ -33,7 +34,7 @@ def _contains_cjk(text: str) -> bool: ) -def repair_mojibake(text: str) -> str: +def repair_mojibake(text: object) -> str: """Repair a single string if it is a Latin-1 rendering of UTF-8 CJK text. Rules, in order: @@ -124,6 +125,11 @@ def parse_args(argv=None): parser.add_argument("--state", default=get_state_path()) parser.add_argument("--backend", choices=("json", "firestore"), default="json") parser.add_argument("--dry-run", action="store_true", help="do not save; print report only") + parser.add_argument( + "--backup-path", + default=None, + help="copy the state file here before applying repairs (apply mode only)", + ) parser.add_argument("--json", action="store_true", dest="json_output") return parser.parse_args(argv) @@ -143,6 +149,8 @@ def main( state = load_state(args.state, backend=args.backend) repaired_state, report = repair_state(state) if not args.dry_run: + if args.backup_path: + shutil.copy2(args.state, args.backup_path) save_state(repaired_state, args.state, backend=args.backend) except Exception as exc: print(f"[repair_mojibake] error: {exc}", file=err) diff --git a/manga_watch/sources/base.py b/manga_watch/sources/base.py index 4dedd12..04ea98c 100644 --- a/manga_watch/sources/base.py +++ b/manga_watch/sources/base.py @@ -57,8 +57,10 @@ def decode_response_bytes( ) -> str: """Decode raw HTTP response bytes to str with a strict fallback order. - Order: declared charset parameter (if any) -> strict UTF-8 (with BOM - stripping) -> apparent encoding -> latin-1 with replacement. + Order: declared charset parameter (if any) -> strict UTF-8 -> apparent + encoding -> latin-1 with replacement. + + A single leading U+FEFF is stripped on every successful decode path. The declared charset and apparent-encoding steps decode with errors="replace", so a single malformed byte never discards the @@ -70,9 +72,13 @@ def decode_response_bytes( match = _CHARSET_RE.search(content_type) if match: try: - return content.decode(match.group(1), errors="replace") + text = content.decode(match.group(1), errors="replace") except LookupError: pass + else: + if text.startswith("\ufeff"): + text = text[1:] + return text try: text = content.decode("utf-8") except UnicodeDecodeError: @@ -83,10 +89,17 @@ def decode_response_bytes( return text if apparent_encoding is not None: try: - return content.decode(apparent_encoding, errors="replace") + text = content.decode(apparent_encoding, errors="replace") except LookupError: pass - return content.decode("latin-1", errors="replace") + else: + if text.startswith("\ufeff"): + text = text[1:] + return text + text = content.decode("latin-1", errors="replace") + if text.startswith("\ufeff"): + text = text[1:] + return text class HttpClient(Protocol): diff --git a/tests/acceptance_traceability.py b/tests/acceptance_traceability.py index d4e0bb4..69657a2 100644 --- a/tests/acceptance_traceability.py +++ b/tests/acceptance_traceability.py @@ -29,7 +29,6 @@ "layer": "formatter", "tests": [ "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", - "tests.test_discord_latest.DiscordLatestTests.test_build_latest_query_response_matrix", ], }, "TC-LATEST-06": { @@ -238,7 +237,6 @@ "TC-SEC-01": { "layer": "security", "tests": [ - "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_matrix", "tests.test_discord_outbound.DiscordOutboundTests.test_discord_channel_client_masks_bot_token_matrix", "tests.test_runner.RunnerTests.test_run_once_redacts_secrets_from_failure_outputs", ], diff --git a/tests/test_acceptance_manifest.py b/tests/test_acceptance_manifest.py new file mode 100644 index 0000000..8aca2cf --- /dev/null +++ b/tests/test_acceptance_manifest.py @@ -0,0 +1,37 @@ +import importlib +import unittest + +from tests.acceptance_traceability import ACCEPTANCE_TRACEABILITY + + +def _iter_manifest_paths(): + seen = set() + for entry in ACCEPTANCE_TRACEABILITY.values(): + for dotted in entry.get("tests", []): + if dotted not in seen: + seen.add(dotted) + yield dotted + + +def resolve_dotted_test_path(dotted: str): + module_name, class_name, method_name = dotted.rsplit(".", 2) + module = importlib.import_module(module_name) + test_class = getattr(module, class_name) + return getattr(test_class, method_name) + + +class AcceptanceManifestTests(unittest.TestCase): + def test_all_manifest_paths_resolve(self): + paths = list(_iter_manifest_paths()) + self.assertTrue(paths, "ACCEPTANCE_TRACEABILITY has no test paths") + unresolved = [] + for dotted in paths: + try: + resolve_dotted_test_path(dotted) + except (ImportError, AttributeError) as exc: + unresolved.append(f"{dotted}: {exc}") + self.assertEqual([], unresolved, f"unresolvable manifest paths: {unresolved}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_check.py b/tests/test_check.py index eb1c049..0d20762 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -1875,6 +1875,14 @@ def test_decode_response_bytes_declared_charset_with_malformed_byte_keeps_charse self.assertIn("あいう", decoded) self.assertIn("\ufffd", decoded) + def test_decode_response_bytes_declared_charset_strips_leading_bom(self): + decoded = decode_response_bytes( + "\ufeffあいう".encode("utf-8"), + content_type="text/html; charset=utf-8", + apparent_encoding=None, + ) + self.assertEqual("あいう", decoded) + def test_get_text_decodes_utf8_when_content_type_has_no_charset(self): title = "科学的に存在しうるクリーチャー娘の観察日誌" session = Utf8XmlSession(Utf8XmlResponse(title.encode("utf-8"))) diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 982158c..10a333b 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -1,6 +1,14 @@ import unittest from pathlib import Path +try: + import yaml +except ImportError: # pragma: no cover - CI has no PyYAML + yaml = None + _HAS_YAML = False +else: + _HAS_YAML = True + def repo_root() -> Path: return Path(__file__).resolve().parents[1] @@ -177,6 +185,31 @@ def step_run_texts(steps: list) -> list: return [step.get("run") or "" for step in steps if isinstance(step, dict)] +def _normalize_for_parity(value): + """Normalize scalar typing so stdlib strings compare with typed YAML. + + _mini_yaml_load keeps every scalar a string while yaml.safe_load + coerces ints/bools; structural drift (not scalar typing) is what + this parity guard watches, so bools/numbers stringify here. + """ + if isinstance(value, bool): + return str(value).lower() + if value is None: + return None + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, dict): + normalized = {} + for key, item in value.items(): + if key is True: + key = "on" + normalized[str(key)] = _normalize_for_parity(item) + return normalized + if isinstance(value, list): + return [_normalize_for_parity(item) for item in value] + return value + + class GitHubWorkflowContractTests(unittest.TestCase): def test_deploy_workflow_exists_and_targets_main_push(self): doc = load_workflow("deploy-production.yml") @@ -298,6 +331,21 @@ def test_rollback_workflow_validates_input_without_gcp_auth_and_updates_both_res "no rollback step updates the Cloud Run Service resource", ) + @unittest.skipUnless(_HAS_YAML, "PyYAML not installed; parity needs yaml.safe_load") + def test_mini_yaml_load_matches_pyyaml_on_target_keys(self): + for name in ("deploy-production.yml", "rollback-production.yml"): + text = read_workflow(name) + expected = yaml.safe_load(text) + if isinstance(expected, dict) and "on" not in expected and True in expected: + expected["on"] = expected.pop(True) + actual = _mini_yaml_load(text) + for key in ("on", "jobs"): + self.assertEqual( + _normalize_for_parity(actual.get(key)), + _normalize_for_parity(expected.get(key)), + f"{name}:{key} drift between _mini_yaml_load and yaml.safe_load", + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_repair_mojibake.py b/tests/test_repair_mojibake.py index d720c40..46f57e7 100644 --- a/tests/test_repair_mojibake.py +++ b/tests/test_repair_mojibake.py @@ -280,6 +280,86 @@ def test_nonexistent_state_path_returns_exit_1_with_error(self): self.assertEqual(exit_code, 1) self.assertIn("[repair_mojibake] error", stderr.getvalue()) + def test_apply_with_backup_path_creates_backup_matching_original(self): + with tempfile.TemporaryDirectory() as tmpdir: + state_path = write_state_file(Path(tmpdir), build_state()) + before = state_path.read_bytes() + backup_path = Path(tmpdir) / "state.backup.json" + + stdout = io.StringIO() + stderr = io.StringIO() + exit_code = main( + argv=["--state", str(state_path), "--backup-path", str(backup_path)], + stdout=stdout, + stderr=stderr, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertTrue(backup_path.exists()) + self.assertEqual(backup_path.read_bytes(), before) + saved = json.loads(state_path.read_text(encoding="utf-8")) + work_id = "champion-cross:6504eab816435" + self.assertEqual(saved["works"][work_id]["latest"]["series_title"], CLEAN_TITLE) + + def test_apply_without_backup_path_creates_no_backup(self): + with tempfile.TemporaryDirectory() as tmpdir: + state_path = write_state_file(Path(tmpdir), build_state()) + backup_path = Path(tmpdir) / "state.backup.json" + + stdout = io.StringIO() + stderr = io.StringIO() + exit_code = main( + argv=["--state", str(state_path)], + stdout=stdout, + stderr=stderr, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertFalse(backup_path.exists()) + + def test_dry_run_with_backup_path_does_not_copy(self): + with tempfile.TemporaryDirectory() as tmpdir: + state_path = write_state_file(Path(tmpdir), build_state()) + before = state_path.read_bytes() + backup_path = Path(tmpdir) / "state.backup.json" + + stdout = io.StringIO() + stderr = io.StringIO() + exit_code = main( + argv=[ + "--state", + str(state_path), + "--dry-run", + "--backup-path", + str(backup_path), + ], + stdout=stdout, + stderr=stderr, + ) + + self.assertEqual(exit_code, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertFalse(backup_path.exists()) + self.assertEqual(state_path.read_bytes(), before) + + def test_apply_with_unwritable_backup_path_returns_exit_1(self): + with tempfile.TemporaryDirectory() as tmpdir: + state_path = write_state_file(Path(tmpdir), build_state()) + backup_path = Path(tmpdir) / "missing-dir" / "state.backup.json" + + stdout = io.StringIO() + stderr = io.StringIO() + exit_code = main( + argv=["--state", str(state_path), "--backup-path", str(backup_path)], + stdout=stdout, + stderr=stderr, + ) + + self.assertEqual(exit_code, 1) + self.assertIn("[repair_mojibake] error", stderr.getvalue()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sources.py b/tests/test_sources.py index 41bd733..30f8050 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -433,11 +433,14 @@ def test_feed_family_normalize_and_canonicalize_matrix(self): }, { "source": "comic-days", + "episode_input": "https://comic-days.com/episode/12207421983746014850?from=share", "episode_url": "https://comic-days.com/episode/12207421983746014850", + "rss_input": "https://comic-days.com/rss/series/13933686331650127004?from=share", + "atom_input": "https://comic-days.com/atom/series/13933686331650127004", "rss_url": "https://comic-days.com/rss/series/13933686331650127004", "series_id": "13933686331650127004", "canonical_input": "https://comic-days.com/episode/12207421983746014850?from=share", - "normalize_cases": (), + "normalize_cases": ("episode", "rss", "atom"), }, { "source": "kuragebunch",