| 1 | """Tests for backfill_transcripts - the post-selection transcript pass (#542). |
| 2 | |
| 3 | search_and_transcribe() fetches transcripts for each search's top-by-views |
| 4 | candidates, but the pipeline's final selection ranks by relevance. When the |
| 5 | two sets are disjoint, every surviving video ships transcript-less. These |
| 6 | tests pin the second-pass backfill that runs from _finalize_items_by_source(). |
| 7 | """ |
| 8 | |
| 9 | import unittest |
| 10 | from unittest import mock |
| 11 | |
| 12 | from lib import schema, youtube_yt |
| 13 | |
| 14 | |
| 15 | def _item(item_id, **metadata): |
| 16 | return schema.SourceItem( |
| 17 | item_id=item_id, |
| 18 | source="youtube", |
| 19 | title=f"Video {item_id}", |
| 20 | body=f"Video {item_id}", |
| 21 | url=f"https://www.youtube.com/watch?v={item_id}", |
| 22 | metadata=dict(metadata), |
| 23 | ) |
| 24 | |
| 25 | |
| 26 | TRANSCRIPT = ( |
| 27 | "Claude Code skills are directories containing a SKILL md file. " |
| 28 | "This video covers the fastest growing skills on GitHub this month. " |
| 29 | "Superpowers enforces test driven development across every phase." |
| 30 | ) |
| 31 | |
| 32 | |
| 33 | class TestBackfillTranscripts(unittest.TestCase): |
| 34 | def setUp(self): |
| 35 | patcher = mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=True) |
| 36 | patcher.start() |
| 37 | self.addCleanup(patcher.stop) |
| 38 | |
| 39 | def test_backfills_survivors_without_transcripts(self): |
| 40 | """The #542 shape: finalized videos disjoint from the fetched set.""" |
| 41 | items = [_item("vidA"), _item("vidB"), _item("vidC"), _item("vidD")] |
| 42 | with mock.patch.object( |
| 43 | youtube_yt, "fetch_transcripts_parallel", |
| 44 | return_value={"vidA": TRANSCRIPT, "vidB": TRANSCRIPT}, |
| 45 | ) as fetch: |
| 46 | youtube_yt.backfill_transcripts(items, topic="claude code skills", depth="default") |
| 47 | |
| 48 | # default budget is 2, attempts capped at need*3 |
| 49 | fetched_ids = fetch.call_args.args[0] |
| 50 | self.assertEqual(fetched_ids, ["vidA", "vidB", "vidC", "vidD"]) |
| 51 | self.assertEqual(items[0].metadata["transcript_snippet"], TRANSCRIPT) |
| 52 | self.assertEqual(items[1].metadata["transcript_snippet"], TRANSCRIPT) |
| 53 | self.assertNotIn("transcript_snippet", items[2].metadata) |
| 54 | self.assertTrue(items[0].metadata.get("transcript_highlights")) |
| 55 | self.assertTrue(items[0].snippet) |
| 56 | |
| 57 | def test_noop_when_budget_already_met(self): |
| 58 | """Survivors that already carry >= limit transcripts trigger no fetch.""" |
| 59 | items = [ |
| 60 | _item("vidA", transcript_snippet=TRANSCRIPT), |
| 61 | _item("vidB", transcript_highlights=["quote"]), |
| 62 | _item("vidC"), |
| 63 | ] |
| 64 | with mock.patch.object(youtube_yt, "fetch_transcripts_parallel") as fetch: |
| 65 | youtube_yt.backfill_transcripts(items, depth="default") |
| 66 | fetch.assert_not_called() |
| 67 | |
| 68 | def test_partial_budget_fetches_only_the_gap(self): |
| 69 | """One transcript present at default depth (limit 2) -> need is 1.""" |
| 70 | items = [ |
| 71 | _item("vidA", transcript_snippet=TRANSCRIPT), |
| 72 | _item("vidB"), |
| 73 | _item("vidC"), |
| 74 | _item("vidD"), |
| 75 | _item("vidE"), |
| 76 | ] |
| 77 | with mock.patch.object( |
| 78 | youtube_yt, "fetch_transcripts_parallel", return_value={"vidB": TRANSCRIPT}, |
| 79 | ) as fetch: |
| 80 | youtube_yt.backfill_transcripts(items, depth="default") |
| 81 | # need=1, attempts capped at need*3=3 |
| 82 | self.assertEqual(fetch.call_args.args[0], ["vidB", "vidC", "vidD"]) |
| 83 | |
| 84 | def test_noop_at_quick_depth(self): |
| 85 | """quick depth has a transcript budget of 0 - never fetch.""" |
| 86 | items = [_item("vidA")] |
| 87 | with mock.patch.object(youtube_yt, "fetch_transcripts_parallel") as fetch: |
| 88 | youtube_yt.backfill_transcripts(items, depth="quick") |
| 89 | fetch.assert_not_called() |
| 90 | |
| 91 | def test_noop_when_ytdlp_missing(self): |
| 92 | items = [_item("vidA")] |
| 93 | with mock.patch.object(youtube_yt, "is_ytdlp_installed", return_value=False): |
| 94 | with mock.patch.object(youtube_yt, "fetch_transcripts_parallel") as fetch: |
| 95 | youtube_yt.backfill_transcripts(items, depth="default") |
| 96 | fetch.assert_not_called() |
| 97 | |
| 98 | def test_skips_captions_disabled_items(self): |
| 99 | """Uploader-disabled captions are not retried and stay marked.""" |
| 100 | items = [_item("vidA", captions_disabled=True), _item("vidB")] |
| 101 | with mock.patch.object( |
| 102 | youtube_yt, "fetch_transcripts_parallel", return_value={"vidB": TRANSCRIPT}, |
| 103 | ) as fetch: |
| 104 | youtube_yt.backfill_transcripts(items, depth="default") |
| 105 | self.assertEqual(fetch.call_args.args[0], ["vidB"]) |
| 106 | |
| 107 | def test_marks_newly_discovered_captions_disabled(self): |
| 108 | """A backfill attempt that reveals no caption tracks feeds quality_nudge.""" |
| 109 | def fake_fetch(video_ids, out_captions_disabled=None, token=None): |
| 110 | if out_captions_disabled is not None: |
| 111 | out_captions_disabled.add("vidA") |
| 112 | return {"vidA": None, "vidB": TRANSCRIPT} |
| 113 | |
| 114 | items = [_item("vidA"), _item("vidB")] |
| 115 | with mock.patch.object(youtube_yt, "fetch_transcripts_parallel", side_effect=fake_fetch): |
| 116 | youtube_yt.backfill_transcripts(items, depth="default") |
| 117 | self.assertTrue(items[0].metadata.get("captions_disabled")) |
| 118 | self.assertNotIn("transcript_snippet", items[0].metadata) |
| 119 | self.assertEqual(items[1].metadata["transcript_snippet"], TRANSCRIPT) |
| 120 | |
| 121 | def test_does_not_overwrite_existing_snippet(self): |
| 122 | items = [_item("vidA")] |
| 123 | items[0].snippet = "ranker-chosen snippet" |
| 124 | with mock.patch.object( |
| 125 | youtube_yt, "fetch_transcripts_parallel", return_value={"vidA": TRANSCRIPT}, |
| 126 | ): |
| 127 | youtube_yt.backfill_transcripts(items, depth="default") |
| 128 | self.assertEqual(items[0].snippet, "ranker-chosen snippet") |
| 129 | self.assertEqual(items[0].metadata["transcript_snippet"], TRANSCRIPT) |
| 130 | |
| 131 | |
| 132 | class TestFinalizeWiring(unittest.TestCase): |
| 133 | def test_finalize_calls_backfill_for_youtube_survivors(self): |
| 134 | from lib import pipeline |
| 135 | |
| 136 | items = [_item("vidA")] |
| 137 | with mock.patch.object(youtube_yt, "backfill_transcripts") as backfill: |
| 138 | pipeline._finalize_items_by_source( |
| 139 | {"youtube": items}, topic="claude code skills", depth="deep", |
| 140 | ) |
| 141 | backfill.assert_called_once() |
| 142 | self.assertEqual(backfill.call_args.kwargs.get("depth"), "deep") |
| 143 | self.assertEqual(backfill.call_args.kwargs.get("topic"), "claude code skills") |
| 144 | |
| 145 | def test_finalize_skips_backfill_in_mock_mode(self): |
| 146 | from lib import pipeline |
| 147 | |
| 148 | items = [_item("vidA")] |
| 149 | with mock.patch.object(youtube_yt, "backfill_transcripts") as backfill: |
| 150 | pipeline._finalize_items_by_source( |
| 151 | {"youtube": items}, topic="claude code skills", depth="default", mock=True, |
| 152 | ) |
| 153 | backfill.assert_not_called() |
| 154 | |
| 155 | |
| 156 | if __name__ == "__main__": |
| 157 | unittest.main() |
| 158 |