Fix thumbnail backoff and watchlist fallback matching
This commit is contained in:
+155
@@ -297,6 +297,27 @@ class WatchlistRefreshJobTests(unittest.TestCase):
|
||||
self.assertEqual(status["errors"], 0)
|
||||
self.assertCountEqual(calls, [("show-a", False), ("show-b", False)])
|
||||
|
||||
def test_refresh_job_marks_interrupted_when_shutdown_requested(self):
|
||||
service = APP.WatchlistRefreshJobs(mock.Mock(), start_worker=False)
|
||||
job = {
|
||||
"id": "refresh-1",
|
||||
"status": "running",
|
||||
"created_at": APP.now_iso(),
|
||||
"updated_at": APP.now_iso(),
|
||||
"completed": 0,
|
||||
"errors": 0,
|
||||
"current_title": None,
|
||||
"message": "",
|
||||
}
|
||||
service.store.all_show_ids = lambda: ["show-a"]
|
||||
service.store.refresh = mock.Mock(return_value={"title": "Show A", "status": "updated"})
|
||||
service.stop_event.set()
|
||||
|
||||
service._run_job(job)
|
||||
|
||||
self.assertEqual(job["status"], "interrupted")
|
||||
self.assertIn("interrupted", job["message"].lower())
|
||||
|
||||
def test_refresh_start_reuses_pending_job(self):
|
||||
class FakeStore:
|
||||
def all_show_ids(self):
|
||||
@@ -407,6 +428,53 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(store.thumbnail_events, {})
|
||||
|
||||
def test_ensure_thumbnail_records_failed_attempt_time_for_retry_backoff(self):
|
||||
now = APP.now_iso()
|
||||
item = {
|
||||
"show_id": "show-thumb-1",
|
||||
"title": "Broken Cover Show",
|
||||
"category": "watching",
|
||||
"downloaded": False,
|
||||
"status": "tracked",
|
||||
"status_message": "Waiting for thumbnail.",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"last_checked": None,
|
||||
"sub_count": 0,
|
||||
"dub_count": 0,
|
||||
"expected_count": 0,
|
||||
"airing_status": None,
|
||||
"sub_latest_episode": None,
|
||||
"dub_latest_episode": None,
|
||||
"animeschedule_route": None,
|
||||
"animeschedule_title": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
with APP.WATCHLIST.lock, APP.WATCHLIST._connect() as conn:
|
||||
APP.WATCHLIST._upsert_conn(conn, item)
|
||||
|
||||
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=RuntimeError("lookup failed")) as fetch_cover, mock.patch.object(
|
||||
APP, "fetch_anidb_cover", side_effect=RuntimeError("fallback failed")
|
||||
) as fetch_fallback:
|
||||
result = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(fetch_cover.call_count, 1)
|
||||
self.assertEqual(fetch_fallback.call_count, 1)
|
||||
failed_item = APP.WATCHLIST.get("show-thumb-1")
|
||||
self.assertTrue(failed_item["thumbnail_checked_at"])
|
||||
self.assertFalse(APP.thumbnail_retry_due(failed_item["thumbnail_checked_at"]))
|
||||
|
||||
with mock.patch.object(APP, "fetch_animeschedule_cover", side_effect=AssertionError("retry window should suppress another lookup")), mock.patch.object(
|
||||
APP, "fetch_anidb_cover", side_effect=AssertionError("retry window should suppress fallback lookup")
|
||||
):
|
||||
second = APP.WATCHLIST.ensure_thumbnail("show-thumb-1")
|
||||
|
||||
self.assertIsNone(second)
|
||||
|
||||
|
||||
class WatchlistCompletionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -496,6 +564,16 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
self.assertEqual(listing["queued_count"], 1)
|
||||
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-1"])
|
||||
|
||||
def test_list_clamps_out_of_range_page_before_querying_rows(self):
|
||||
self.seed_watchlist_item(show_id="show-1", title="Alpha Show", category="watching")
|
||||
self.seed_watchlist_item(show_id="show-2", title="Beta Show", category="watching")
|
||||
|
||||
listing = APP.WATCHLIST.list(page=3, per_page=1, category="watching")
|
||||
|
||||
self.assertEqual(listing["page"], 2)
|
||||
self.assertEqual(listing["pages"], 2)
|
||||
self.assertEqual([item["show_id"] for item in listing["items"]], ["show-2"])
|
||||
|
||||
def test_queue_refresh_marks_item_queued_without_sync_refresh(self):
|
||||
self.seed_watchlist_item(show_id="show-11", title="Refresh Show", category="watching", status="updated")
|
||||
|
||||
@@ -543,6 +621,32 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
self.assertEqual(result["item"]["status"], "queued")
|
||||
self.assertIn("Refresh queued", result["message"])
|
||||
|
||||
def test_refresh_uses_bounded_request_timeout(self):
|
||||
self.seed_watchlist_item(show_id="show-1", title="Example Show")
|
||||
snapshot = {
|
||||
"show_id": "show-1",
|
||||
"title": "Example Show",
|
||||
"episodes": {"sub": ["1"], "dub": []},
|
||||
}
|
||||
schedule = {
|
||||
"route": "example-show",
|
||||
"title": "Example Show",
|
||||
"episodes": 12,
|
||||
"status": "Finished",
|
||||
}
|
||||
|
||||
with mock.patch.object(APP, "fetch_show_episode_snapshot", return_value=snapshot) as fetch_snapshot, mock.patch.object(
|
||||
APP, "resolve_animeschedule_anime", return_value=schedule
|
||||
) as resolve_schedule, mock.patch.object(APP.WatchlistStore, "ensure_thumbnail", return_value=None):
|
||||
APP.WATCHLIST.refresh("show-1")
|
||||
|
||||
fetch_snapshot.assert_called_once_with("show-1", timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS)
|
||||
resolve_schedule.assert_called_once_with(
|
||||
"Example Show",
|
||||
route=None,
|
||||
timeout=APP.WATCHLIST_REFRESH_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
def test_sync_downloaded_job_to_watchlist_resolves_missing_show_id_from_search(self):
|
||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
||||
|
||||
@@ -562,6 +666,33 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
self.assertEqual(prepared["show_id"], "show-42")
|
||||
self.assertEqual(prepared["title"], "Queue Show")
|
||||
|
||||
def test_prepare_download_job_accepts_equivalent_season_notation(self):
|
||||
job = {"query": "Queue Show 2nd Season", "mode": "sub", "result_index": 1, "title": "Queue Show 2nd Season", "show_id": ""}
|
||||
|
||||
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 2"}]):
|
||||
prepared = APP.prepare_download_job(dict(job))
|
||||
|
||||
self.assertEqual(prepared["show_id"], "show-42")
|
||||
self.assertEqual(prepared["title"], "Queue Show Season 2")
|
||||
|
||||
def test_sync_downloaded_job_to_watchlist_rejects_ambiguous_fallback_match(self):
|
||||
job = {"query": "Queue Show", "mode": "sub", "result_index": 1, "title": "Queue Show"}
|
||||
|
||||
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Different Anime"}]):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
APP.sync_downloaded_job_to_watchlist(job)
|
||||
|
||||
self.assertIn("title mismatch", str(ctx.exception).lower())
|
||||
|
||||
def test_sync_downloaded_job_to_watchlist_rejects_cross_season_match(self):
|
||||
job = {"query": "Queue Show Season 2", "mode": "sub", "result_index": 1, "title": "Queue Show Season 2"}
|
||||
|
||||
with mock.patch.object(APP, "search_anime", return_value=[{"id": "show-42", "title": "Queue Show Season 1"}]):
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
APP.sync_downloaded_job_to_watchlist(job)
|
||||
|
||||
self.assertIn("title mismatch", str(ctx.exception).lower())
|
||||
|
||||
|
||||
class WatchlistQueuedRefreshRecoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -730,6 +861,7 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
with mock.patch.object(APP, "initialize_runtime") as initialize_runtime, mock.patch.object(
|
||||
APP, "ThreadingHTTPServer", return_value=server
|
||||
), mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
|
||||
shutdown_runtime.return_value = True
|
||||
APP.main()
|
||||
|
||||
initialize_runtime.assert_not_called()
|
||||
@@ -741,6 +873,7 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
try:
|
||||
with mock.patch.object(APP, "shutdown_runtime") as shutdown_runtime:
|
||||
shutdown_runtime.return_value = True
|
||||
APP.reset_runtime(wait=True)
|
||||
|
||||
shutdown_runtime.assert_called_once_with(wait=True, cancel_active_downloads=None)
|
||||
@@ -748,6 +881,25 @@ class StartupBehaviorTests(unittest.TestCase):
|
||||
finally:
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
def test_reset_runtime_refuses_to_clear_globals_when_shutdown_does_not_finish(self):
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
try:
|
||||
current_config = APP.CONFIG
|
||||
current_watchlist = APP.WATCHLIST
|
||||
current_queue = APP.DOWNLOAD_QUEUE
|
||||
current_refresh = APP.WATCHLIST_REFRESH
|
||||
with mock.patch.object(APP, "shutdown_runtime", return_value=False):
|
||||
with self.assertRaises(RuntimeError):
|
||||
APP.reset_runtime(wait=True)
|
||||
|
||||
self.assertIs(APP.CONFIG, current_config)
|
||||
self.assertIs(APP.WATCHLIST, current_watchlist)
|
||||
self.assertIs(APP.DOWNLOAD_QUEUE, current_queue)
|
||||
self.assertIs(APP.WATCHLIST_REFRESH, current_refresh)
|
||||
finally:
|
||||
APP.reset_runtime(wait=True)
|
||||
APP.initialize_runtime(start_workers=False)
|
||||
|
||||
def test_save_runtime_config_replaces_runtime_config(self):
|
||||
original = APP.CONFIG
|
||||
try:
|
||||
@@ -876,6 +1028,9 @@ class TemplateHelperTests(unittest.TestCase):
|
||||
self.assertIn("function startSerialPoll(callback, intervalMs)", APP.WATCHLIST_HTML)
|
||||
self.assertIn("startSerialPoll(() => loadQueue(), 2500)", APP.INDEX_HTML)
|
||||
self.assertIn("startSerialPoll(async () => {", APP.WATCHLIST_HTML)
|
||||
self.assertIn("window.clearTimeout(timer);", APP.INDEX_HTML)
|
||||
self.assertIn('window.addEventListener("pagehide", stop, { once: true });', APP.INDEX_HTML)
|
||||
self.assertIn('window.addEventListener("beforeunload", stop, { once: true });', APP.WATCHLIST_HTML)
|
||||
|
||||
def test_watchlist_page_polls_when_queued_items_exist(self):
|
||||
self.assertIn("queuedCount: 0", APP.WATCHLIST_HTML)
|
||||
|
||||
Reference in New Issue
Block a user