diff --git a/resources/views/videos/types/generic.blade.php b/resources/views/videos/types/generic.blade.php
index 36e4206..f484770 100644
--- a/resources/views/videos/types/generic.blade.php
+++ b/resources/views/videos/types/generic.blade.php
@@ -980,9 +980,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
@foreach ($recommendedVideos as $recVideo)
-
-
+
@endforeach
@else
diff --git a/resources/views/videos/types/match.blade.php b/resources/views/videos/types/match.blade.php
index b1cd764..901a854 100644
--- a/resources/views/videos/types/match.blade.php
+++ b/resources/views/videos/types/match.blade.php
@@ -3144,9 +3144,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
@foreach ($recommendedVideos as $recVideo)
-
-
+
@endforeach
@else
diff --git a/resources/views/videos/types/music.blade.php b/resources/views/videos/types/music.blade.php
index 120380f..9c7f6af 100644
--- a/resources/views/videos/types/music.blade.php
+++ b/resources/views/videos/types/music.blade.php
@@ -972,9 +972,13 @@
@if ($recommendedVideos && $recommendedVideos->count() > 0)
@foreach ($recommendedVideos as $recVideo)
-
-
+
@endforeach
@else
diff --git a/tests/Feature/Phase4WatchTest.php b/tests/Feature/Phase4WatchTest.php
new file mode 100644
index 0000000..13b0224
--- /dev/null
+++ b/tests/Feature/Phase4WatchTest.php
@@ -0,0 +1,180 @@
+withoutMiddleware([
+ VerifyCsrfToken::class,
+ \Illuminate\Session\Middleware\AuthenticateSession::class,
+ ]);
+ }
+
+ // ─── Comment component ────────────────────────────
+
+ public function test_comment_header_uses_singular_count_for_one_comment(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo();
+ Comment::create([
+ 'user_id' => $user->id,
+ 'video_id' => $video->id,
+ 'body' => 'the only comment',
+ ]);
+ $this->actingAs($user);
+
+ $html = view('components.video-comments', ['video' => $video])->render();
+
+ $this->assertStringContainsString('1 Comment', $html);
+ $this->assertStringNotContainsString('1 Comments', $html);
+ }
+
+ public function test_comment_header_breaks_out_replies_from_top_level(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo();
+ $parent = Comment::create([
+ 'user_id' => $user->id,
+ 'video_id' => $video->id,
+ 'body' => 'top',
+ ]);
+ Comment::create([
+ 'user_id' => $user->id,
+ 'video_id' => $video->id,
+ 'parent_id' => $parent->id,
+ 'body' => 'reply 1',
+ ]);
+ Comment::create([
+ 'user_id' => $user->id,
+ 'video_id' => $video->id,
+ 'parent_id' => $parent->id,
+ 'body' => 'reply 2',
+ ]);
+ $this->actingAs($user);
+
+ $html = view('components.video-comments', ['video' => $video])->render();
+
+ $this->assertStringContainsString('1 Comment', $html, 'Top-level count is 1.');
+ $this->assertStringContainsString('2 replies', $html, 'Replies are counted separately.');
+ }
+
+ public function test_each_comment_timestamp_is_a_lc_permalink(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo();
+ $c = Comment::create([
+ 'user_id' => $user->id,
+ 'video_id' => $video->id,
+ 'body' => 'hi',
+ ]);
+ $this->actingAs($user);
+
+ $html = view('components.video-comments', ['video' => $video])->render();
+
+ $this->assertStringContainsString('?lc=' . $c->id . '#comment-' . $c->id, $html);
+ $this->assertStringContainsString('ytc-permalink', $html);
+ }
+
+ // ─── Description markdown ────────────────────────
+
+ public function test_leaked_markdown_headers_render_as_html(): void
+ {
+ $out = HtmlSanitizer::render("Intro line\n## Letra\nla la\n### Chorus");
+
+ $this->assertStringContainsString('Letra
', $out);
+ $this->assertStringContainsString('Chorus
', $out);
+ $this->assertStringNotContainsString('## Letra', $out);
+ $this->assertStringNotContainsString('### Chorus', $out);
+ }
+
+ public function test_normal_plain_text_description_passes_through(): void
+ {
+ $out = HtmlSanitizer::render("hello world\nhow are you");
+
+ $this->assertStringContainsString('hello world', $out);
+ $this->assertStringContainsString('how are you', $out);
+ $this->assertStringNotContainsString('', $out);
+ }
+
+ // ─── Download access gate ────────────────────────
+
+ public function test_disabled_download_returns_403_for_mp4(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
+
+ $this->get(route('videos.download', $video))->assertStatus(403);
+ }
+
+ public function test_disabled_download_returns_403_for_mp3(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo(['download_access' => 'disabled']);
+
+ $this->get(route('videos.downloadMp3', $video))->assertStatus(403);
+ }
+
+ public function test_registered_download_denies_guests(): void
+ {
+ [$user, $video] = $this->makeUserAndVideo(['download_access' => 'registered']);
+
+ $this->get(route('videos.download', $video))->assertStatus(403);
+ }
+
+ // ─── Up Next anchor cards ─────────────────────────
+
+ public function test_up_next_sidebar_renders_anchor_cards_with_hrefs(): void
+ {
+ [$owner, $target] = $this->makeUserAndVideo();
+ [, $rec] = $this->makeUserAndVideo();
+
+ $html = $this->get(route('videos.show', $target))->getContent();
+
+ $this->assertStringContainsString('