DEV: Adding specs and refactors (#21)

Add specs for the following reports:

* Reading time
* Activity calendar
* Best posts
* Best topics
* Top words
* Most viewed tags
* Most viewed categories

Did some minor UI and ruby refactors for related components.

Also made a minor change to the Activity calendar, to show a title based
on the number of posts or if the user was active on hover.

Still missing specs for:

* Reactions
* FBFF

And the newly added reports that don't yet have UI components.
This commit is contained in:
Martin Brennan 2025-12-01 10:08:30 +10:00 committed by GitHub
parent 09ee91ad67
commit 26a77d4c69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 943 additions and 206 deletions

View File

@ -20,9 +20,13 @@ module DiscourseRewind
TopicViewItem
.joins(:topic)
.joins("INNER JOIN categories ON categories.id = topics.category_id")
.where(user: user)
.where(viewed_at: date)
.where(categories: { id: user.guardian.allowed_category_ids })
.where(
user: user,
viewed_at: date,
categories: {
id: user.guardian.allowed_category_ids,
},
)
.group("categories.id, categories.name")
.order("COUNT(*) DESC")
.limit(4)

View File

@ -22,9 +22,7 @@ module DiscourseRewind
.joins(:topic)
.joins("INNER JOIN topic_tags ON topic_tags.topic_id = topics.id")
.joins("INNER JOIN tags ON tags.id = topic_tags.tag_id")
.where(user: user)
.where(viewed_at: date)
.where(tags: { id: Tag.visible(user.guardian).pluck(:id) })
.where(user: user, viewed_at: date, tags: { id: Tag.visible(user.guardian).pluck(:id) })
.group("tags.id, tags.name")
.order("COUNT(DISTINCT topic_views.topic_id) DESC")
.limit(4)

View File

@ -85,7 +85,7 @@ module DiscourseRewind
end
def sort_and_limit(reactions)
reactions.sort_by { |_, v| -v }.first(5).reverse.to_h
reactions.sort_by { |_, value| -value }.take(5).reverse.to_h
end
end
end

View File

@ -5,6 +5,144 @@
module DiscourseRewind
module Action
class ReadingTime < BaseReport
POPULAR_BOOKS = {
"The Metamorphosis" => {
reading_time: 3120,
isbn: "978-0553213690",
series: false,
},
"The Little Prince" => {
reading_time: 5400,
isbn: "978-0156012195",
series: false,
},
"Animal Farm" => {
reading_time: 7200,
isbn: "978-0451526342",
series: false,
},
"The Alchemist" => {
reading_time: 10_800,
isbn: "978-0061122415",
series: false,
},
"The Great Gatsby" => {
reading_time: 12_600,
isbn: "978-0743273565",
series: false,
},
"The Hitchhiker's Guide to the Galaxy" => {
reading_time: 12_600,
isbn: "978-0345391803",
series: false,
},
"Fahrenheit 451" => {
reading_time: 15_000,
isbn: "978-1451673319",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"1984" => {
reading_time: 16_800,
isbn: "978-0451524935",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
series: false,
},
"The Hunger Games" => {
reading_time: 19_740,
isbn: "978-0439023481",
series: false,
},
"To Kill a Mockingbird" => {
reading_time: 22_800,
isbn: "978-0061120084",
series: false,
},
"Harry Potter and the Sorcerer's Stone" => {
reading_time: 24_600,
isbn: "978-0590353427",
series: true,
},
"Pride and Prejudice" => {
reading_time: 25_200,
isbn: "978-1503290563",
series: false,
},
"The Hobbit" => {
reading_time: 27_000,
isbn: "978-0547928227",
series: false,
},
"Little Women" => {
reading_time: 30_000,
isbn: "978-0147514011",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"One Hundred Years of Solitude" => {
reading_time: 46_800,
isbn: "978-0060883287",
series: false,
},
"The Lord of the Rings" => {
reading_time: 108_000,
isbn: "978-0544003415",
series: true,
},
"The Complete works of Shakespeare" => {
reading_time: 180_000,
isbn: "978-1853268953",
series: true,
},
"The Game of Thrones Series" => {
reading_time: 360_000,
isbn: "978-0007477159",
series: true,
},
"Malazan Book of the Fallen" => {
reading_time: 720_000,
isbn: "978-0765348821",
series: true,
},
"Terry Pratchett's Discworld series" => {
reading_time: 1_440_000,
isbn: "978-9123684458",
series: true,
},
"The Wandering Inn web series" => {
reading_time: 2_160_000,
isbn: "the-wandering-inn",
series: true,
},
"The Combined Cosmere works + Wheel of Time" => {
reading_time: 2_880_000,
isbn: "978-0812511819",
series: true,
},
"The Star Trek novels" => {
reading_time: 3_600_000,
isbn: "978-1852860691",
series: true,
},
}.symbolize_keys
FakeData = {
data: {
reading_time: 2_880_000,
@ -26,7 +164,7 @@ module DiscourseRewind
{
data: {
reading_time: reading_time,
book: book[:title],
book: book[:title].to_s,
isbn: book[:isbn],
series: book[:series],
},
@ -34,168 +172,15 @@ module DiscourseRewind
}
end
def popular_books
{
"The Hunger Games" => {
reading_time: 19_740,
isbn: "978-0439023481",
series: false,
},
"The Metamorphosis" => {
reading_time: 3120,
isbn: "978-0553213690",
series: false,
},
"To Kill a Mockingbird" => {
reading_time: 22_800,
isbn: "978-0061120084",
series: false,
},
"Pride and Prejudice" => {
reading_time: 25_200,
isbn: "978-1503290563",
series: false,
},
"1984" => {
reading_time: 16_800,
isbn: "978-0451524935",
series: false,
},
"The Lord of the Rings" => {
reading_time: 108_000,
isbn: "978-0544003415",
series: true,
},
"Harry Potter and the Sorcerer's Stone" => {
reading_time: 24_600,
isbn: "978-0590353427",
series: true,
},
"The Great Gatsby" => {
reading_time: 12_600,
isbn: "978-0743273565",
series: false,
},
"The Little Prince" => {
reading_time: 5400,
isbn: "978-0156012195",
series: false,
},
"Animal Farm" => {
reading_time: 7200,
isbn: "978-0451526342",
series: false,
},
"The Catcher in the Rye" => {
reading_time: 18_000,
isbn: "978-0316769488",
series: false,
},
"Jane Eyre" => {
reading_time: 34_200,
isbn: "978-0141441146",
series: false,
},
"Fahrenheit 451" => {
reading_time: 15_000,
isbn: "978-1451673319",
series: false,
},
"The Hobbit" => {
reading_time: 27_000,
isbn: "978-0547928227",
series: false,
},
"The Da Vinci Code" => {
reading_time: 37_800,
isbn: "978-0307474278",
series: false,
},
"Little Women" => {
reading_time: 30_000,
isbn: "978-0147514011",
series: false,
},
"One Hundred Years of Solitude" => {
reading_time: 46_800,
isbn: "978-0060883287",
series: false,
},
"And Then There Were None" => {
reading_time: 16_200,
isbn: "978-0062073488",
series: false,
},
"The Alchemist" => {
reading_time: 10_800,
isbn: "978-0061122415",
series: false,
},
"The Hitchhiker's Guide to the Galaxy" => {
reading_time: 12_600,
isbn: "978-0345391803",
series: false,
},
"The Complete works of Shakespeare" => {
reading_time: 180_000,
isbn: "978-1853268953",
series: true,
},
"The Game of Thrones Series" => {
reading_time: 360_000,
isbn: "978-0007477159",
series: true,
},
"Malazan Book of the Fallen" => {
reading_time: 720_000,
isbn: "978-0765348821",
series: true,
},
"Terry Pratchetts Discworld series" => {
reading_time: 1_440_000,
isbn: "978-9123684458",
series: true,
},
"The Wandering Inn web series" => {
reading_time: 2_160_000,
isbn: "the-wandering-inn",
series: true,
},
"The Combined Cosmere works + Wheel of Time" => {
reading_time: 2_880_000,
isbn: "978-0812511819",
series: true,
},
"The Star Trek novels" => {
reading_time: 3_600_000,
isbn: "978-1852860691",
series: true,
},
}.symbolize_keys
end
def best_book_fit(reading_time)
reading_time_rest = reading_time
books = []
best_fit =
POPULAR_BOOKS
.select { |_, v| v[:reading_time] > reading_time }
.min_by { |_, v| v[:reading_time] }
while reading_time_rest > 0
best_fit = popular_books.min_by { |_, v| (v[:reading_time] - reading_time_rest).abs }
break if best_fit.nil?
return if best_fit.nil?
books << best_fit.first
reading_time_rest -= best_fit.last[:reading_time]
end
return if books.empty?
book_title =
books.group_by { |book| book }.transform_values(&:count).max_by { |_, count| count }.first
{
title: book_title,
isbn: popular_books[book_title][:isbn],
series: popular_books[book_title][:series],
}
{ title: best_fit.first, isbn: best_fit.last[:isbn], series: best_fit.last[:series] }
end
end
end

View File

@ -10,7 +10,6 @@ module DiscourseRewind
{ word: "you", score: 80 },
{ word: "overachieved", score: 70 },
{ word: "assume", score: 60 },
{ word: "week", score: 50 },
],
identifier: "top-words",
}
@ -79,7 +78,13 @@ module DiscourseRewind
LIMIT 100
SQL
word_score = words.map { { word: _1.original_word, score: _1.ndoc + _1.nentry } }
word_score =
words
.map do |word_data|
{ word: word_data.original_word, score: word_data.ndoc + word_data.nentry }
end
.sort_by! { |w| -w[:score] }
.take(5)
{ data: word_score, identifier: "top-words" }
end

View File

@ -5,7 +5,8 @@ module DiscourseRewind
#
# @example
# ::DiscourseRewind::Rewind::Fetch.call(
# guardian: guardian
# guardian: guardian,
# params: { year: 2023, username: 'codinghorror' }
# )
#
class FetchReports

View File

@ -28,6 +28,32 @@ export default class ActivityCalendar extends Component {
return moment().month(monthIndex).format("MMM");
}
@action
computeCellTitle(cell) {
if (!cell || !cell.date) {
return "";
}
const date = moment(cell.date).format("LL");
if (cell.visited && cell.post_count === 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_no_posts",
{ date }
);
} else if (cell.post_count > 0) {
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.visited_with_posts",
{ date, count: cell.post_count }
);
}
return i18n(
"discourse_rewind.reports.activity_calendar.cell_title.no_activity",
{ date }
);
}
@action
computeClass(count) {
if (!count) {
@ -107,7 +133,7 @@ export default class ActivityCalendar extends Component {
{{#each row as |cell|}}
<td
data-date={{cell.date}}
title={{cell.date}}
title={{this.computeCellTitle cell}}
class={{concatClass
"rewind-calendar-cell"
(this.computeClass cell.post_count)

View File

@ -1,24 +1,26 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class BestPosts extends Component {
rank(idx) {
return idx + 1;
rankClass(idx) {
return `rank-${idx + 1}`;
}
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-posts">
<h2 class="rewind-report-title">{{i18n
<h2 class="rewind-report-title">
{{i18n
"discourse_rewind.reports.best_posts.title"
count=@report.data.length
}}</h2>
}}
</h2>
<div class="rewind-report-container">
{{#each @report.data as |post idx|}}
<div class={{concat "rewind-card" " rank-" (this.rank idx)}}>
<div class={{concatClass "rewind-card" (this.rankClass idx)}}>
<span class="best-posts -rank"></span>
<span class="best-posts -rank"></span>
<div class="best-posts__post"><p>{{htmlSafe

View File

@ -1,27 +1,31 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import replaceEmoji from "discourse/helpers/replace-emoji";
import getURL from "discourse/lib/get-url";
import { i18n } from "discourse-i18n";
export default class BestTopics extends Component {
rank(idx) {
return idx + 1;
rankClass(idx) {
return `rank-${idx + 1}`;
}
<template>
{{#if @report.data.length}}
<div class="rewind-report-page -best-topics">
<h2 class="rewind-report-title">{{i18n
<h2 class="rewind-report-title">
{{i18n
"discourse_rewind.reports.best_topics.title"
count=@report.data.length
}}</h2>
}}
</h2>
<div class="rewind-report-container">
<div class="rewind-card">
{{#each @report.data as |topic idx|}}
<a
href={{concat "/t/-/" topic.topic_id}}
class={{concat "best-topics__topic" " rank-" (this.rank idx)}}
href={{getURL (concat "/t/-/" topic.topic_id)}}
class={{concatClass "best-topics__topic" (this.rankClass idx)}}
>
<span class="best-topics -rank"></span>
<span class="best-topics -rank"></span>

View File

@ -1,28 +1,23 @@
import Component from "@glimmer/component";
import { i18n } from "discourse-i18n";
import WordCard from "discourse/plugins/discourse-rewind/discourse/components/reports/top-words/word-card";
export default class WordCards extends Component {
get topWords() {
return this.args.report.data.sort((a, b) => b.score - a.score).slice(0, 5);
}
<template>
<div class="rewind-report-page -top-words">
<div class="rewind-report-container">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.top_words.title"
}}</h2>
<div class="cards-container">
{{#each this.topWords as |entry index|}}
<WordCard
@word={{entry.word}}
@count={{entry.score}}
@index={{index}}
/>
{{/each}}
</div>
const WordCards = <template>
<div class="rewind-report-page -top-words">
<div class="rewind-report-container">
<h2 class="rewind-report-title">{{i18n
"discourse_rewind.reports.top_words.title"
}}</h2>
<div class="cards-container">
{{#each @report.data as |entry index|}}
<WordCard
@word={{entry.word}}
@count={{entry.score}}
@index={{index}}
/>
{{/each}}
</div>
</div>
</template>
}
</div>
</template>;
export default WordCards;

View File

@ -13,6 +13,12 @@ en:
reports:
activity_calendar:
title: Activity Calendar
cell_title:
visited_no_posts: "You visited and lurked on %{date}, but made no posts."
visited_with_posts:
one: "You made %{count} post on %{date}."
other: "You made %{count} posts on %{date}."
no_activity: "You weren't around on %{date}."
top_words:
title: Word Usage
reading_time:

View File

@ -0,0 +1,68 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::ActivityCalendar do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:other_user, :user)
fab!(:post_1) { Fabricate(:post, user: user, created_at: Date.new(2021, 1, 15)) }
fab!(:post_2) { Fabricate(:post, user: user, created_at: Date.new(2021, 6, 27)) }
fab!(:post_3) { Fabricate(:post, user: user, created_at: Date.new(2021, 6, 27)) }
fab!(:post_4) { Fabricate(:post, user: user, created_at: Date.new(2021, 11, 27)) }
fab!(:post_5) { Fabricate(:post, user: other_user, created_at: Date.new(2021, 11, 27)) }
fab!(:post_6) { Fabricate(:post, user: user, created_at: Date.new(2022, 02, 27)) }
fab!(:user_visit_1) do
UserVisit.create!(
user_id: user.id,
visited_at: Date.new(2021, 3, 10),
posts_read: 5,
time_read: 120,
)
end
fab!(:user_visit_2) do
UserVisit.create!(
user_id: user.id,
visited_at: Date.new(2021, 4, 18),
posts_read: 12,
time_read: 1200,
)
end
fab!(:user_visit_3) do
UserVisit.create!(
user_id: other_user.id,
visited_at: Date.new(2021, 7, 24),
posts_read: 12,
time_read: 1200,
)
end
it "returns an entry for all days of the last year" do
result = call_report
expect(result[:data].map { |d| d[:date] }.count).to eq(365)
end
it "counts up posts for the user on days they were made in the year" do
result = call_report
expect(result[:data].find { |d| d[:date] == Date.new(2021, 1, 15) }[:post_count]).to eq(1)
expect(result[:data].find { |d| d[:date] == Date.new(2021, 6, 27) }[:post_count]).to eq(2)
expect(result[:data].find { |d| d[:date] == Date.new(2021, 11, 27) }[:post_count]).to eq(1)
expect(result[:data].find { |d| d[:date] == Date.new(2022, 2, 27) }).to be_nil
end
it "marks dates as visited for the user in the year" do
result = call_report
expect(result[:data].find { |d| d[:date] == Date.new(2021, 3, 10) }[:visited]).to eq(true)
expect(result[:data].find { |d| d[:date] == Date.new(2021, 4, 18) }[:visited]).to eq(true)
expect(result[:data].find { |d| d[:date] == Date.new(2021, 5, 1) }[:visited]).to eq(false)
end
context "when a post is deleted" do
before { post_1.trash! }
it "does not count" do
result = call_report
expect(result[:data].find { |d| d[:date] == Date.new(2021, 1, 15) }[:post_count]).to eq(0)
end
end
end

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::BestPosts do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:post_1) { Fabricate(:post, created_at: random_datetime, user: user, post_number: 3) }
fab!(:post_2) { Fabricate(:post, created_at: random_datetime, user: user, post_number: 2) }
fab!(:post_3) { Fabricate(:post, created_at: random_datetime, user: user, post_number: 10) }
fab!(:post_4) { Fabricate(:post, created_at: random_datetime, user: user, post_number: 6) }
fab!(:post_5) { Fabricate(:post, created_at: random_datetime, user: user, post_number: 1) }
describe ".call" do
it "returns top 3 posts ordered by like count" do
post_4.update!(like_count: 15)
post_3.update!(like_count: 13)
post_1.update!(like_count: 11)
post_2.update!(like_count: 9)
post_5.update!(like_count: 7)
expect(call_report[:data]).to eq(
[
{
post_number: post_4.post_number,
topic_id: post_4.topic_id,
like_count: post_4.like_count,
reply_count: post_4.reply_count,
excerpt:
post_4.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
},
{
post_number: post_3.post_number,
topic_id: post_3.topic_id,
like_count: post_3.like_count,
reply_count: post_3.reply_count,
excerpt:
post_3.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
},
{
post_number: post_1.post_number,
topic_id: post_1.topic_id,
like_count: post_1.like_count,
reply_count: post_1.reply_count,
excerpt:
post_1.excerpt(200, { strip_links: true, remap_emoji: true, keep_images: true }),
},
],
)
end
context "when a post is deleted" do
before { post_1.trash!(Discourse.system_user) }
it "is not included" do
expect(call_report[:data].map { |d| d[:post_number] }).not_to include(post_1.post_number)
end
end
context "when a post is made by another user" do
before { post_1.update!(user: Fabricate(:user)) }
it "is not included" do
expect(call_report[:data].map { |d| d[:post_number] }).not_to include(post_1.post_number)
end
end
end
end

View File

@ -0,0 +1,61 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::BestTopics do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:topic_1) { Fabricate(:topic, user: user, created_at: random_datetime) }
fab!(:topic_2) { Fabricate(:topic, user: user, created_at: random_datetime) }
fab!(:topic_3) { Fabricate(:topic, user: user, created_at: random_datetime) }
fab!(:topic_4) { Fabricate(:topic, user: user, created_at: random_datetime) }
fab!(:topic_5) { Fabricate(:topic, user: user, created_at: random_datetime) }
before { TopTopic.refresh! }
describe ".call" do
it "returns top 3 topics ordered by yearly_score" do
TopTopic.find_by(topic_id: topic_1.id).update!(yearly_score: 15)
TopTopic.find_by(topic_id: topic_2.id).update!(yearly_score: 10)
TopTopic.find_by(topic_id: topic_3.id).update!(yearly_score: 6)
TopTopic.find_by(topic_id: topic_4.id).update!(yearly_score: 11)
TopTopic.find_by(topic_id: topic_5.id).update!(yearly_score: 13)
expect(call_report[:data]).to eq(
[
{
topic_id: topic_1.id,
title: topic_1.title,
excerpt: topic_1.excerpt,
yearly_score: 15,
},
{
topic_id: topic_5.id,
title: topic_5.title,
excerpt: topic_5.excerpt,
yearly_score: 13,
},
{
topic_id: topic_4.id,
title: topic_4.title,
excerpt: topic_4.excerpt,
yearly_score: 11,
},
],
)
end
context "when a topic is deleted" do
before { topic_1.trash!(Discourse.system_user) }
it "is not included" do
expect(call_report[:data].map { |d| d[:topic_id] }).not_to include(topic_1.id)
end
end
context "when a topic" do
before { topic_1.update!(user: Fabricate(:user)) }
it "is not included" do
expect(call_report[:data].map { |d| d[:topic_id] }).not_to include(topic_1.id)
end
end
end
end

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::MostViewedCategories do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:other_user, :user)
fab!(:category_1) { Fabricate(:category, name: "Technology") }
fab!(:category_2) { Fabricate(:category, name: "Science") }
fab!(:category_3) { Fabricate(:category, name: "Philosophy") }
fab!(:category_4) { Fabricate(:category, name: "Literature") }
fab!(:category_5) { Fabricate(:category, name: "History") }
fab!(:topic_1) { Fabricate(:topic, category: category_1) }
fab!(:topic_2) { Fabricate(:topic, category: category_1) }
fab!(:topic_3) { Fabricate(:topic, category: category_2) }
fab!(:topic_4) { Fabricate(:topic, category: category_3) }
fab!(:topic_5) { Fabricate(:topic, category: category_4) }
fab!(:topic_6) { Fabricate(:topic, category: category_5) }
describe ".call" do
it "returns top 4 most viewed categories ordered by view count" do
# Category 1: 2 views
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", user.id, Date.new(2021, 4, 20))
# Category 2: 1 view
TopicViewItem.add(topic_3.id, "127.0.0.3", user.id, Date.new(2021, 5, 10))
# Category 3: 1 view
TopicViewItem.add(topic_4.id, "127.0.0.4", user.id, Date.new(2021, 6, 5))
# Category 4: 3 views (same topic, multiple views)
TopicViewItem.add(topic_5.id, "127.0.0.5", user.id, Date.new(2021, 7, 1))
TopicViewItem.add(topic_5.id, "127.0.0.6", user.id, Date.new(2021, 8, 15))
TopicViewItem.add(topic_5.id, "127.0.0.7", user.id, Date.new(2021, 9, 20))
# Category 5: 0 views
result = call_report
expect(result[:identifier]).to eq("most-viewed-categories")
expect(result[:data].length).to eq(4)
expect(result[:data]).to eq(
[
{ category_id: category_1.id, name: "Technology" },
{ category_id: category_2.id, name: "Science" },
{ category_id: category_3.id, name: "Philosophy" },
{ category_id: category_4.id, name: "Literature" },
],
)
end
it "only includes categories the user can see (no read-restricted/private categories)" do
group = Fabricate(:group)
private_category = Fabricate(:private_category, group: group)
private_topic = Fabricate(:topic, category: private_category)
TopicViewItem.add(private_topic.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
result = call_report
expect(result[:data].map { |c| c[:category_id] }).not_to include(private_category.id)
end
it "filters by date range" do
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", user.id, Date.new(2020, 12, 31))
result = call_report
expect(result[:data].length).to eq(1)
expect(result[:data].first[:category_id]).to eq(category_1.id)
end
it "only counts views for the specific user" do
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", other_user.id, Date.new(2021, 4, 20))
result = call_report
expect(result[:data].length).to eq(1)
expect(result[:data].first[:category_id]).to eq(category_1.id)
end
it "returns empty array when no views" do
result = call_report
expect(result[:identifier]).to eq("most-viewed-categories")
expect(result[:data]).to eq([])
end
end
end

View File

@ -0,0 +1,117 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::MostViewedTags do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:other_user, :user)
fab!(:tag_1) { Fabricate(:tag, name: "ruby") }
fab!(:tag_2) { Fabricate(:tag, name: "javascript") }
fab!(:tag_3) { Fabricate(:tag, name: "python") }
fab!(:tag_4) { Fabricate(:tag, name: "golang") }
fab!(:tag_5) { Fabricate(:tag, name: "rust") }
fab!(:topic_1, :topic)
fab!(:topic_2, :topic)
fab!(:topic_3, :topic)
fab!(:topic_4, :topic)
fab!(:topic_5, :topic)
before do
SiteSetting.tagging_enabled = true
topic_1.tags = [tag_1]
topic_2.tags = [tag_1]
topic_3.tags = [tag_2]
topic_4.tags = [tag_3]
topic_5.tags = [tag_4]
end
describe ".call" do
it "returns top 4 most viewed tags ordered by view count" do
# Tag 1 (ruby): 2 views (2 different topics)
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", user.id, Date.new(2021, 4, 20))
# Tag 2 (javascript): 1 view
TopicViewItem.add(topic_3.id, "127.0.0.3", user.id, Date.new(2021, 5, 10))
# Tag 3 (python): 1 view
TopicViewItem.add(topic_4.id, "127.0.0.4", user.id, Date.new(2021, 6, 5))
# Tag 4 (golang): 3 views (same topic, multiple views)
TopicViewItem.add(topic_5.id, "127.0.0.5", user.id, Date.new(2021, 7, 1))
TopicViewItem.add(topic_5.id, "127.0.0.6", user.id, Date.new(2021, 8, 15))
TopicViewItem.add(topic_5.id, "127.0.0.7", user.id, Date.new(2021, 9, 20))
# Tag 5 (rust): 0 views
result = call_report
expect(result[:data]).to eq(
[
{ tag_id: tag_1.id, name: "ruby" },
{ tag_id: tag_2.id, name: "javascript" },
{ tag_id: tag_3.id, name: "python" },
{ tag_id: tag_4.id, name: "golang" },
],
)
end
it "only includes tags the user can see (no restricted tags)" do
group = Fabricate(:group)
tag_group = Fabricate(:tag_group, tags: [tag_5])
tag_group.permissions = { group.name => TagGroupPermission.permission_types[:full] }
tag_group.save!
restricted_topic = Fabricate(:topic)
restricted_topic.tags = [tag_5]
TopicViewItem.add(restricted_topic.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
result = call_report
expect(result[:data].map { |t| t[:tag_id] }).not_to include(tag_5.id)
end
it "filters by date range" do
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", user.id, Date.new(2020, 12, 31))
result = call_report
expect(result[:data].length).to eq(1)
expect(result[:data].first[:tag_id]).to eq(tag_1.id)
end
it "only counts views for the specific user" do
TopicViewItem.add(topic_1.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(topic_2.id, "127.0.0.2", other_user.id, Date.new(2021, 4, 20))
result = call_report
expect(result[:data].length).to eq(1)
expect(result[:data].first[:tag_id]).to eq(tag_1.id)
end
it "counts distinct topics per tag" do
multi_tag_topic = Fabricate(:topic)
multi_tag_topic.tags = [tag_1, tag_2]
TopicViewItem.add(multi_tag_topic.id, "127.0.0.1", user.id, Date.new(2021, 3, 15))
TopicViewItem.add(multi_tag_topic.id, "127.0.0.2", user.id, Date.new(2021, 4, 20))
result = call_report
tag_1_data = result[:data].find { |t| t[:tag_id] == tag_1.id }
tag_2_data = result[:data].find { |t| t[:tag_id] == tag_2.id }
expect(tag_1_data).not_to be_nil
expect(tag_2_data).not_to be_nil
end
it "returns empty array when no views" do
result = call_report
expect(result[:data]).to eq([])
end
end
end

View File

@ -0,0 +1,146 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::ReadingTime do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:other_user, :user)
fab!(:user_visit_1) do
UserVisit.create!(
user_id: user.id,
visited_at: Date.new(2021, 3, 10),
posts_read: 5,
time_read: 100,
)
end
fab!(:user_visit_2) do
UserVisit.create!(
user_id: user.id,
visited_at: Date.new(2021, 4, 18),
posts_read: 12,
time_read: 1000,
)
end
fab!(:user_visit_3) do
UserVisit.create!(
user_id: other_user.id,
visited_at: Date.new(2021, 7, 24),
posts_read: 8,
time_read: 1200,
)
end
def new_target_time_read(value)
value - 1000
end
it "calculates reading time for the year correctly" do
result = call_report
expect(result[:data][:reading_time]).to eq(1100)
end
it "matches the correct book based on reading time" do
result = call_report
expect(result[:data][:book]).to eq("The Metamorphosis")
user_visit_1.update!(time_read: new_target_time_read(5300))
result = call_report
expect(result[:data][:book]).to eq("The Little Prince")
user_visit_1.update!(time_read: new_target_time_read(7100))
result = call_report
expect(result[:data][:book]).to eq("Animal Farm")
user_visit_1.update!(time_read: new_target_time_read(10_700))
result = call_report
expect(result[:data][:book]).to eq("The Alchemist")
user_visit_1.update!(time_read: new_target_time_read(12_500))
result = call_report
expect(result[:data][:book]).to eq("The Great Gatsby")
user_visit_1.update!(time_read: new_target_time_read(14_900))
result = call_report
expect(result[:data][:book]).to eq("Fahrenheit 451")
user_visit_1.update!(time_read: new_target_time_read(16_100))
result = call_report
expect(result[:data][:book]).to eq("And Then There Were None")
user_visit_1.update!(time_read: new_target_time_read(16_700))
result = call_report
expect(result[:data][:book]).to eq("1984")
user_visit_1.update!(time_read: new_target_time_read(17_900))
result = call_report
expect(result[:data][:book]).to eq("The Catcher in the Rye")
user_visit_1.update!(time_read: new_target_time_read(19_640))
result = call_report
expect(result[:data][:book]).to eq("The Hunger Games")
user_visit_1.update!(time_read: new_target_time_read(22_700))
result = call_report
expect(result[:data][:book]).to eq("To Kill a Mockingbird")
user_visit_1.update!(time_read: new_target_time_read(24_500))
result = call_report
expect(result[:data][:book]).to eq("Harry Potter and the Sorcerer's Stone")
user_visit_1.update!(time_read: new_target_time_read(25_100))
result = call_report
expect(result[:data][:book]).to eq("Pride and Prejudice")
user_visit_1.update!(time_read: new_target_time_read(26_900))
result = call_report
expect(result[:data][:book]).to eq("The Hobbit")
user_visit_1.update!(time_read: new_target_time_read(29_900))
result = call_report
expect(result[:data][:book]).to eq("Little Women")
user_visit_1.update!(time_read: new_target_time_read(34_100))
result = call_report
expect(result[:data][:book]).to eq("Jane Eyre")
user_visit_1.update!(time_read: new_target_time_read(37_700))
result = call_report
expect(result[:data][:book]).to eq("The Da Vinci Code")
user_visit_1.update!(time_read: new_target_time_read(46_700))
result = call_report
expect(result[:data][:book]).to eq("One Hundred Years of Solitude")
user_visit_1.update!(time_read: new_target_time_read(107_900))
result = call_report
expect(result[:data][:book]).to eq("The Lord of the Rings")
user_visit_1.update!(time_read: new_target_time_read(179_900))
result = call_report
expect(result[:data][:book]).to eq("The Complete works of Shakespeare")
user_visit_1.update!(time_read: new_target_time_read(359_900))
result = call_report
expect(result[:data][:book]).to eq("The Game of Thrones Series")
user_visit_1.update!(time_read: new_target_time_read(719_900))
result = call_report
expect(result[:data][:book]).to eq("Malazan Book of the Fallen")
user_visit_1.update!(time_read: new_target_time_read(1_439_900))
result = call_report
expect(result[:data][:book]).to eq("Terry Pratchett's Discworld series")
user_visit_1.update!(time_read: new_target_time_read(2_159_900))
result = call_report
expect(result[:data][:book]).to eq("The Wandering Inn web series")
user_visit_1.update!(time_read: new_target_time_read(2_879_900))
result = call_report
expect(result[:data][:book]).to eq("The Combined Cosmere works + Wheel of Time")
user_visit_1.update!(time_read: new_target_time_read(3_599_900))
result = call_report
expect(result[:data][:book]).to eq("The Star Trek novels")
end
end

View File

@ -0,0 +1,135 @@
# frozen_string_literal: true
RSpec.describe DiscourseRewind::Action::TopWords do
fab!(:date) { Date.new(2021).all_year }
fab!(:user)
fab!(:other_user, :user)
fab!(:post1) do
Fabricate(
:post,
user: user,
raw: "apple orange banana apple apple orange",
created_at: random_datetime,
)
end
fab!(:post2) do
Fabricate(:post, user: user, raw: "cucumber tomato banana orange", created_at: random_datetime)
end
fab!(:post3) do
Fabricate(:post, user: user, raw: "grape watermelon mango", created_at: random_datetime)
end
fab!(:post4) do
Fabricate(:post, user: user, raw: "apple banana grape apple", created_at: random_datetime)
end
fab!(:post5) do
Fabricate(:post, user: user, raw: "apple orange apple apple", created_at: random_datetime)
end
fab!(:other_user_post) do
Fabricate(:post, user: other_user, raw: "apple apple apple", created_at: random_datetime)
end
before do
SearchIndexer.enable
[post1, post2, post3, post4, post5, other_user_post].each do |post|
SearchIndexer.index(post, force: true)
end
end
describe ".call" do
it "limits top words to 5" do
result = call_report
expect(result[:data].length).to eq(5)
end
it "returns top words ordered by frequency" do
result = call_report
expect(result[:identifier]).to eq("top-words")
words = result[:data]
expect(words.first[:word]).to eq("apple")
expect(words.second[:word]).to eq("orange")
expect(words.third[:word]).to eq("banana")
expect(words.map { |w| w[:word] }).to include("apple", "orange", "banana", "grape")
expect(words.map { |w| w[:score] }).to eq(words.map { |w| w[:score] }.sort.reverse)
end
context "when a post is deleted" do
before do
post1.trash!(Discourse.system_user)
post1.post_search_data.destroy!
end
it "does not include words from deleted posts" do
result = call_report
words = result[:data]
apple = words.find { |w| w[:word] == "apple" }
expect(apple[:score]).to be < 9
end
end
context "when posts are from another user" do
it "does not include words from other users' posts" do
result = call_report
words = result[:data]
apple_score = words.find { |w| w[:word] == "apple" }[:score]
expect(apple_score).to be < 12
end
end
context "with a large number of posts and words" do
before do
# Create posts with different frequencies of non-stop words
10.times do |i|
post =
Fabricate(
:post,
user: user,
raw: "#{frequent_word} #{frequent_word} #{frequent_word} #{infrequent_word}",
created_at: random_datetime,
)
SearchIndexer.index(post, force: true)
end
end
let(:frequent_word) { "zucchini" }
let(:infrequent_word) { "xylophone" }
it "ranks high frequency words higher than low frequency words" do
result = call_report
words = result[:data]
frequent_word_entry = words.find { |w| w[:word] == frequent_word }
infrequent_word_entry = words.find { |w| w[:word] == infrequent_word }
expect(frequent_word_entry).to be_present
expect(infrequent_word_entry).to be_present
expect(frequent_word_entry[:score]).to be > infrequent_word_entry[:score]
end
end
end
context "when in rails development mode" do
before { Rails.env.stubs(:development?).returns(true) }
it "returns fake data" do
result = call_report
expect(result[:identifier]).to eq("top-words")
expect(result[:data].length).to eq(5)
expect(result[:data].first[:word]).to eq("seven")
expect(result[:data].first[:score]).to eq(100)
expect(result[:data].second[:word]).to eq("longest")
expect(result[:data].second[:score]).to eq(90)
end
end
end

15
spec/plugin_helper.rb Normal file
View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module DiscourseRewindSpecHelper
def call_report
# user + date should be defined via fab! in the spec
described_class.call(user:, date:, guardian: user.guardian)
end
def random_datetime
# date should be defined via fab! in the spec
date.to_a.sample.to_datetime + rand(0..23).hours + rand(0..59).minutes + rand(0..59).seconds
end
end
RSpec.configure { |config| config.include DiscourseRewindSpecHelper }

View File

@ -31,6 +31,17 @@ RSpec.describe(DiscourseRewind::FetchReports) do
it { is_expected.to fail_to_find_a_model(:year) }
end
context "in development mode" do
before do
Rails.env.stubs(:development?).returns(true)
freeze_time DateTime.parse("2021-06-22")
end
it "finds the year no matter what month" do
expect(result.year).to eq(2021)
end
end
context "when reports is cached" do
before { freeze_time DateTime.parse("2021-12-22") }