From a605494cc9cad3cdac9faa4e66b04faa054ddcfe Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Fri, 6 Nov 2026 22:38:47 +0000 Subject: [PATCH] fix: require multiple root commits for hot topics Amp-Thread-ID: https://ampcode.com/threads/T-01a1b2f0-9ebd-7043-abb5-30948e4779ad Co-authored-by: Pauline Vos --- KNOWLEDGE_BASE.md | 2 +- app/Queries/HotHeadlinesQuery.php | 1 + .../Commands/RefreshHeadlineSummariesTest.php | 13 +++--- .../Unit/Jobs/GenerateHeadlineSummaryTest.php | 19 +++++--- .../Jobs/GenerateHotHeadlineSummariesTest.php | 2 +- .../Middleware/HandleInertiaRequestsTest.php | 11 ++--- tests/Unit/Queries/HotHeadlinesQueryTest.php | 44 +++++++++---------- 7 files changed, 48 insertions(+), 44 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index b0c1628a03..ef8e0aa72b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -234,7 +234,7 @@ Commits, replies, quotes, scheduling, terminal submissions, and app (signed API) Approved top-level commits about specific developments in technology, current affairs or substantive real-world events can receive brief, shared headlines. A headline identifies a particular release, feature, event, change, problem or finding, not a broad category such as "Laravel" or "AI tools". Technical and developer developments carry the most weight. /dev/null community activity also qualifies, including livestreams, Tom, app betas and feature updates. Quote commits include the directly quoted post's text when it is approved and still available, so a brief reaction can share the development of the post it quotes. Replies are not used for headline classification. Casual small talk, broad technology chat without a specific development, and evergreen lifestyle or hobby advice are skipped. OpenAI's Decisions API assesses this text for suitability and checks the closest existing headline names, shortlisted by semantic similarity; when none match, DeepSeek through OpenCode generates a headline from the same text. Posts can be processed together, and newly generated headlines about the same development share the first headline rather than creating duplicates. Different developments involving the same product do not share a headline merely because of that product. No account metadata is sent. A headline expires after fourteen days without a new commit link. This categorization does not change the original post and does not yet add visible headline labels to the interface. -The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**, with bold headlines and a truncated recap underneath when one is available. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation. +The desktop sidebar shows signed-in members up to five **Hot topics** above **Who to follow**, with bold headlines and a truncated recap underneath when one is available. These aggregate approved top-level commits posted in the past eight hours, with extra weight for trending discussions. A topic needs at least two eligible top-level commits visible to you; replies do not count, and the list can show fewer than five topics or none. Only each author's strongest post contributes to a topic's ranking, so repeated posts from one profile do not lift it. The list respects mutes and blocks. The sidebar no longer repeats your own profile summary; your profile is still available from the main navigation. Hot topics can also receive a short AI-written recap based on approved, available top-level commits from the past eight hours and public replies in their threads, including nested replies. Both the AI usefulness check and the recap writer receive the reply context: a question can support a recap when its answers provide useful substance. The sample includes one strongest root post per author, up to one hundred roots, plus up to one hundred of the newest replies across those threads. Recaps aim to explain the underlying situation or event in one or two short sentences, rather than cataloguing individual posts. They are not independently verified reporting and can be reused for up to two hours. Only post and reply text and the headline are sent to the AI providers, without account metadata. Removing a linked top-level source commit, rejecting it, or deleting its author's account clears the associated recap. Removing a reply or its author's account does not clear an existing recap: its paraphrased contribution can remain until the recap expires, at most two hours after generation. Signed-in members can open a headline to read its available recap above commits linked to that headline and posted within the past week, shown newest first. This list does not use semantic search and does not count toward the commit search limit. Expired recaps are omitted, and a recap is hidden if linked public threads include a post or reply from an author the viewer has muted or blocked. The results respect visibility, mutes and blocks too. diff --git a/app/Queries/HotHeadlinesQuery.php b/app/Queries/HotHeadlinesQuery.php index 604e23d649..e80c472218 100644 --- a/app/Queries/HotHeadlinesQuery.php +++ b/app/Queries/HotHeadlinesQuery.php @@ -51,6 +51,7 @@ public function get(?User $viewer = null): array ->select('headlines.id', 'headlines.name') ->selectRaw('sum(contributions.commits_count)::bigint as commits_count, count(*) as authors_count, sum(1 + greatest(0::float8, contributions.hot_score - ?::float8)) as score', [$baseline]) ->groupBy('headlines.id', 'headlines.name') + ->havingRaw('sum(contributions.commits_count) > 1') ->orderByDesc('score') ->orderByDesc('authors_count') ->orderBy('headlines.name') diff --git a/tests/Feature/Commands/RefreshHeadlineSummariesTest.php b/tests/Feature/Commands/RefreshHeadlineSummariesTest.php index 1aa8a144bd..59110a7e90 100644 --- a/tests/Feature/Commands/RefreshHeadlineSummariesTest.php +++ b/tests/Feature/Commands/RefreshHeadlineSummariesTest.php @@ -11,12 +11,12 @@ $fresh = Headline::factory()->create(['name' => 'Database indexing', 'summary' => 'Previous recap.', 'summary_created_at' => now()->subHour()]); $missing = Headline::factory()->create(['name' => 'Editor release']); $cold = Headline::factory()->create(['summary' => 'Cold recap.', 'summary_created_at' => now()->subHour()]); - Commit::factory()->hasAttached($fresh)->create(['body' => 'An index made our dashboard faster.']); - Commit::factory()->hasAttached($missing)->create(['body' => 'The editor added keyboard shortcuts.']); + Commit::factory()->count(2)->hasAttached($fresh)->create(['body' => 'An index made our dashboard faster.']); + Commit::factory()->count(2)->hasAttached($missing)->create(['body' => 'The editor added keyboard shortcuts.']); Commit::factory()->hasAttached($cold)->create(['created_at' => now()->subHours(9)]); $provider = $this->mock(HeadlineProvider::class); - $provider->shouldReceive('summarize')->once()->with('Database indexing', ['An index made our dashboard faster.'])->andReturn('An index improved dashboard query performance.'); - $provider->shouldReceive('summarize')->once()->with('Editor release', ['The editor added keyboard shortcuts.'])->andReturn('The editor release adds keyboard shortcuts.'); + $provider->shouldReceive('summarize')->once()->with('Database indexing', ['An index made our dashboard faster.', 'An index made our dashboard faster.'])->andReturn('An index improved dashboard query performance.'); + $provider->shouldReceive('summarize')->once()->with('Editor release', ['The editor added keyboard shortcuts.', 'The editor added keyboard shortcuts.'])->andReturn('The editor release adds keyboard shortcuts.'); $this->artisan('headlines:refresh')->expectsOutputToContain('Processed 2 hot topics for recap refresh.')->assertSuccessful(); @@ -28,7 +28,7 @@ it('retains a fresh recap when no useful replacement can be made', function (): void { $headline = Headline::factory()->create(['summary' => 'Previous recap.', 'summary_created_at' => now()->subHour()]); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnNull(); $this->artisan('headlines:refresh')->assertSuccessful(); @@ -40,6 +40,7 @@ it('does not publish a refresh over a changed recap or changed sources', function (string $change): void { $headline = Headline::factory()->create(['summary' => 'Previous recap.', 'summary_created_at' => now()->subHour()]); $source = Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnUsing(function () use ($headline, $source, $change): string { match ($change) { 'recap' => $headline->update(['summary' => 'Newer recap.', 'summary_created_at' => now()]), @@ -62,7 +63,7 @@ it('sanitizes provider failures and leaves the previous recap intact', function (): void { $headline = Headline::factory()->create(['summary' => 'Previous recap.', 'summary_created_at' => now()->subHour()]); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andThrow(new RuntimeException('Sensitive provider response')); expect(fn () => $this->artisan('headlines:refresh')->run())->toThrow(function (HeadlineSummaryFailed $exception) use ($headline): void { diff --git a/tests/Unit/Jobs/GenerateHeadlineSummaryTest.php b/tests/Unit/Jobs/GenerateHeadlineSummaryTest.php index 66df07f603..bb9c9eac0c 100644 --- a/tests/Unit/Jobs/GenerateHeadlineSummaryTest.php +++ b/tests/Unit/Jobs/GenerateHeadlineSummaryTest.php @@ -43,6 +43,7 @@ it('gives the recap decision and writer the same public reply context without classifying replies', function (): void { $headline = Headline::factory()->create(['name' => 'Vector index memory usage']); $root = Commit::factory()->hasAttached($headline)->create(['body' => 'Has anyone measured index memory?']); + Commit::factory()->for($root->user)->hasAttached($headline)->create(['hot_score' => -1]); $reply = Commit::factory()->for($root, 'parent')->create(['body' => 'Our index used 2 GB for one million vectors.']); Commit::factory()->for($reply, 'parent')->create(['body' => 'Reducing dimensions halved that in our test.']); Commit::factory()->pending()->for($root, 'parent')->create(); @@ -82,6 +83,7 @@ it('bounds reply context to the newest hundred replies across the selected threads', function (): void { $headline = Headline::factory()->create(); $root = Commit::factory()->hasAttached($headline)->create(['body' => 'An index memory regression.']); + Commit::factory()->for($root->user)->hasAttached($headline)->create(['hot_score' => -1]); $older = Commit::factory()->hasAttached($headline)->create(['created_at' => now()->subHours(9)]); Commit::factory()->for($older, 'parent')->create(['body' => 'A reply in an expired thread.']); Commit::factory()->for($root, 'parent')->create(['body' => 'Outside the reply sample.', 'created_at' => now()->subMinute()]); @@ -96,7 +98,7 @@ it('reuses a recap until exactly two hours and then regenerates it', function (): void { $headline = Headline::factory()->create(); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->twice()->andReturn('First snapshot.', 'Second snapshot.'); $job = new GenerateHeadlineSummary($headline->id); $action = resolve(CreateHeadlineSummary::class); @@ -114,7 +116,7 @@ it('leaves no recap when the posts are insufficient and can reconsider new information', function (): void { $headline = Headline::factory()->create(); - Commit::factory()->hasAttached($headline)->create(['body' => 'Interesting!']); + Commit::factory()->count(2)->hasAttached($headline)->create(['body' => 'Interesting!']); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->twice()->andReturn(null, 'A grounded recap.'); $job = new GenerateHeadlineSummary($headline->id); $action = resolve(CreateHeadlineSummary::class); @@ -132,18 +134,20 @@ match ($state) { 'missing' => $headline->delete(), 'cold' => Commit::factory()->hasAttached($headline)->create(['created_at' => now()->subHours(9)]), - 'empty' => Commit::factory()->hasAttached($headline)->create(['body' => '']), + 'empty' => Commit::factory()->count(2)->hasAttached($headline)->create(['body' => '']), + 'single' => Commit::factory()->hasAttached($headline)->create(), }; $this->mock(HeadlineProvider::class)->shouldNotReceive('summarize'); new GenerateHeadlineSummary($headline->id)->handle(resolve(CreateHeadlineSummary::class)); expect($headline->fresh()?->summary)->toBeNull(); -})->with(['missing', 'cold', 'empty']); +})->with(['missing', 'cold', 'empty', 'single']); it('discards a generated recap if its source becomes unavailable before publication', function (string $change): void { $headline = Headline::factory()->create(); $commit = Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnUsing(function () use ($commit, $headline, $change): string { match ($change) { 'deleted' => $commit->delete(), @@ -164,6 +168,7 @@ it('discards a recap if a reply is removed during generation', function (string $change): void { $headline = Headline::factory()->create(); $root = Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->hasAttached($headline)->create(); $reply = Commit::factory()->for($root, 'parent')->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnUsing(function () use ($reply, $change): string { match ($change) { @@ -182,7 +187,7 @@ it('keeps a fresh recap published while another generator was running', function (): void { $headline = Headline::factory()->create(); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnUsing(function () use ($headline): string { $headline->update(['summary' => 'Already published.', 'summary_created_at' => now()]); @@ -196,7 +201,7 @@ it('coalesces overlapping generators before calling the provider', function (): void { $headline = Headline::factory()->create(); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andReturnUsing(function (): string { Fiber::suspend(); @@ -214,7 +219,7 @@ it('does not copy provider content into queue failures', function (): void { $headline = Headline::factory()->create(); - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); $this->mock(HeadlineProvider::class)->shouldReceive('summarize')->once()->andThrow(new RuntimeException('Sensitive provider response')); expect(fn () => new GenerateHeadlineSummary($headline->id)->handle(resolve(CreateHeadlineSummary::class))) diff --git a/tests/Unit/Jobs/GenerateHotHeadlineSummariesTest.php b/tests/Unit/Jobs/GenerateHotHeadlineSummariesTest.php index a62d3e68a6..8ad7e7f2cd 100644 --- a/tests/Unit/Jobs/GenerateHotHeadlineSummariesTest.php +++ b/tests/Unit/Jobs/GenerateHotHeadlineSummariesTest.php @@ -16,7 +16,7 @@ ->sequence(fn (Sequence $sequence): array => ['name' => 'Topic '.$sequence->index]) ->create(); foreach ($headlines as $headline) { - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); } $fresh = $headlines->first(); diff --git a/tests/Unit/Middleware/HandleInertiaRequestsTest.php b/tests/Unit/Middleware/HandleInertiaRequestsTest.php index ff618c7705..5742ccdb41 100644 --- a/tests/Unit/Middleware/HandleInertiaRequestsTest.php +++ b/tests/Unit/Middleware/HandleInertiaRequestsTest.php @@ -406,8 +406,8 @@ $user->muting()->attach($muted); $topic = Headline::factory()->create(['name' => 'Database indexes', 'summary' => 'Developers compare indexing strategies.', 'summary_created_at' => now()]); $hidden = Headline::factory()->create(); - Commit::factory()->hasAttached($topic)->create(); - Commit::factory()->for($muted)->hasAttached($hidden)->create(); + Commit::factory()->count(2)->hasAttached($topic)->create(); + Commit::factory()->count(2)->for($muted)->hasAttached($hidden)->create(); $headers = [ 'X-Inertia' => 'true', 'X-Inertia-Version' => (string) resolve(HandleInertiaRequests::class)->version(request()), @@ -422,7 +422,7 @@ ->assertOk()->assertJsonMissingPath('props.hotTopics'); $next = Headline::factory()->create(['name' => 'Accessible developer tools']); - Commit::factory()->hasAttached($next)->create(); + Commit::factory()->count(2)->hasAttached($next)->create(); $this->withHeaders([ ...$headers, @@ -439,7 +439,7 @@ $viewer = User::factory()->create(); $headlines = Headline::factory()->count(5)->create(['summary' => 'A fresh recap.', 'summary_created_at' => now()]); foreach ($headlines as $headline) { - Commit::factory()->hasAttached($headline)->create(); + Commit::factory()->count(2)->hasAttached($headline)->create(); } $request = Request::create(route('starred.index'), 'GET'); @@ -457,7 +457,7 @@ it('omits expired recaps from hot topics', function (): void { $this->freezeSecond(); $topic = Headline::factory()->create(['summary' => 'An expired recap.', 'summary_created_at' => now()->subHours(2)]); - Commit::factory()->hasAttached($topic)->create(); + Commit::factory()->count(2)->hasAttached($topic)->create(); $this->actingAs(User::factory()->create())->get(route('starred.index')) ->assertOk()->assertInertia(fn ($page) => $page @@ -470,6 +470,7 @@ $viewer->{$relationship}()->attach($hidden); $topic = Headline::factory()->create(['summary' => 'A recap with hidden contributions.', 'summary_created_at' => now()]); $root = Commit::factory()->hasAttached($topic)->create(); + Commit::factory()->hasAttached($topic)->create(); if ($isReply) { Commit::factory()->for($hidden)->for($root, 'parent')->create(); } else { diff --git a/tests/Unit/Queries/HotHeadlinesQueryTest.php b/tests/Unit/Queries/HotHeadlinesQueryTest.php index fd2a61e17f..36cfebfc23 100644 --- a/tests/Unit/Queries/HotHeadlinesQueryTest.php +++ b/tests/Unit/Queries/HotHeadlinesQueryTest.php @@ -10,7 +10,7 @@ use App\Services\TheAlgorithm; use Illuminate\Database\Eloquent\Factories\Sequence; -it('aggregates shared headlines with their page identifiers without returning unused headlines', function (): void { +it('returns only headlines with more than one eligible root commit', function (): void { $shared = Headline::factory()->create(['name' => 'Shared topic']); $other = Headline::factory()->create(['name' => 'Other topic']); Headline::factory()->create(); @@ -19,7 +19,6 @@ expect(resolve(HotHeadlinesQuery::class)->get())->toBe([ ['id' => $shared->id, 'name' => 'Shared topic', 'commits_count' => 2, 'score' => 2.0], - ['id' => $other->id, 'name' => 'Other topic', 'commits_count' => 1, 'score' => 1.0], ]); }); @@ -31,21 +30,21 @@ $algorithm = resolve(TheAlgorithm::class); $engagement = new ThreadEngagement(stargazers: 6, quoters: 0, repliers: 0, repliersAnsweredByAuthor: 0); Commit::factory()->count(4)->hasAttached($broad)->create(); - Commit::factory()->hasAttached($hot)->create(['hot_score' => $algorithm->score(now(), $engagement)]); - Commit::factory()->hasAttached($older)->create([ + Commit::factory()->count(2)->hasAttached($hot)->create(['hot_score' => $algorithm->score(now(), $engagement)]); + Commit::factory()->count(2)->hasAttached($older)->create([ 'created_at' => now()->subHours(6), 'hot_score' => $algorithm->score(now()->subHours(6), $engagement), ]); - Commit::factory()->hasAttached($quiet)->create(['created_at' => now()->subHour()]); + Commit::factory()->count(2)->hasAttached($quiet)->create(['created_at' => now()->subHour()]); $rows = resolve(HotHeadlinesQuery::class)->get(); - expect(array_column($rows, 'name'))->toBe(['Broad topic', 'Hot topic', 'Older hot topic', 'Quiet topic']) - ->and(array_column($rows, 'commits_count'))->toBe([4, 1, 1, 1]) - ->and($rows[0]['score'])->toBe(4.0) - ->and($rows[1]['score'])->toEqualWithDelta(3.0, 0.000002) - ->and($rows[2]['score'])->toEqualWithDelta(2.0, 0.000002) - ->and($rows[3]['score'])->toBe(1.0); + expect(array_column($rows, 'name'))->toBe(['Hot topic', 'Broad topic', 'Older hot topic', 'Quiet topic']) + ->and(array_column($rows, 'commits_count'))->toBe([2, 4, 2, 2]) + ->and($rows[0]['score'])->toEqualWithDelta(6.0, 0.000004) + ->and($rows[1]['score'])->toBe(4.0) + ->and($rows[2]['score'])->toEqualWithDelta(4.0, 0.000004) + ->and($rows[3]['score'])->toBe(2.0); }); it('uses the rolling eight hours of posting time rather than headline link time', function (): void { @@ -79,9 +78,7 @@ $deleted->headlines()->attach($headline); $deleted->delete(); - expect(resolve(HotHeadlinesQuery::class)->get($viewer))->toBe([ - ['id' => $headline->id, 'name' => $headline->name, 'commits_count' => 1, 'score' => 1.0], - ]); + expect(resolve(HotHeadlinesQuery::class)->get($viewer))->toBe([]); }); it('excludes muted and blocked authors from both topic discovery and aggregation', function (): void { @@ -100,9 +97,7 @@ Commit::factory()->for($author)->create()->headlines()->attach([$shared->id, $hidden->id]); } - expect(resolve(HotHeadlinesQuery::class)->get($viewer))->toBe([ - ['id' => $shared->id, 'name' => 'Shared topic', 'commits_count' => 1, 'score' => 1.0], - ]); + expect(resolve(HotHeadlinesQuery::class)->get($viewer))->toBe([]); }); it('counts only each authors strongest commit per headline without rewarding repeated posts in ties', function (): void { @@ -116,6 +111,7 @@ 'hot_score' => $algorithm->score(now()->subHours(6), new ThreadEngagement(stargazers: 14, quoters: 0, repliers: 0, repliersAnsweredByAuthor: 0)), ]); $strongest->headlines()->attach([$repeated->id, $other->id]); + Commit::factory()->for($author)->hasAttached($other)->create(); Commit::factory()->for($author)->hasAttached($repeated)->create([ 'hot_score' => $algorithm->score(now(), new ThreadEngagement(stargazers: 2, quoters: 0, repliers: 0, repliersAnsweredByAuthor: 0)), ]); @@ -125,7 +121,7 @@ $rows = resolve(HotHeadlinesQuery::class)->get(); expect(array_column($rows, 'name'))->toBe(['B broad topic', 'A other topic', 'Z repeated topic']) - ->and(array_column($rows, 'commits_count'))->toBe([4, 1, 3]) + ->and(array_column($rows, 'commits_count'))->toBe([4, 2, 3]) ->and($rows[0]['score'])->toBe(4.0) ->and($rows[1]['score'])->toEqualWithDelta(3.0, 0.000002) ->and($rows[2]['score'])->toEqualWithDelta(3.0, 0.000002); @@ -136,21 +132,21 @@ $newer = Headline::factory()->create(['name' => 'B newer']); $volume = Headline::factory()->create(['name' => 'C volume']); $hot = Headline::factory()->create(['name' => 'A hot']); - Commit::factory()->hasAttached($older)->create(['created_at' => now()->subHour()]); - Commit::factory()->hasAttached($newer)->create(); + Commit::factory()->count(2)->hasAttached($older)->create(['created_at' => now()->subHour()]); + Commit::factory()->count(2)->hasAttached($newer)->create(); Commit::factory()->count(2)->hasAttached($volume)->create(['created_at' => now()->subHours(2)]); - Commit::factory()->hasAttached($hot)->create([ + Commit::factory()->count(2)->hasAttached($hot)->create([ 'hot_score' => resolve(TheAlgorithm::class)->score(now(), new ThreadEngagement(stargazers: 2, quoters: 0, repliers: 0, repliersAnsweredByAuthor: 0)), ]); - Commit::factory()->create()->headlines()->attach( + Commit::factory()->count(2)->hasAttached( Headline::factory()->count(7)->sequence(fn (Sequence $sequence): array => ['name' => 'D topic '.$sequence->index])->create(), - ); + )->create(); $query = resolve(HotHeadlinesQuery::class); $rows = $query->get(); expect(array_column($rows, 'name'))->toBe([ - 'C volume', 'A hot', 'A older', 'B newer', 'D topic 0', + 'A hot', 'A older', 'B newer', 'C volume', 'D topic 0', ]) ->and($query->get())->toBe($rows); });