Surface read counts and sharpen the reader: - story_views table + RecordStoryView on /api/article (background, filter-guarded); "Popular this week" home rail via TrendingStories; read-count badge and reading-time chip decorated onto every listing - reader: signed-in-only read-aloud (TTS), native share/copy, and an Aa typography popover (size/serif/sepia) persisted per device - real alt text on card/reader/related/search images; time-of-day Pete greeting on the home hero - harden exec() to skip (not panic) on a nil DB so background writes can't crash on a closed handle Tests: story_views_test.go, trending_test.go. Suite green, CSS rebuilt.
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
package storage
|
|
|
|
import "testing"
|
|
|
|
// insertClassified is a tiny helper to seed a visible story and return its id.
|
|
func insertClassified(t *testing.T, guid, headline, content string) int64 {
|
|
t.Helper()
|
|
s := &Story{
|
|
GUID: guid,
|
|
Headline: headline,
|
|
Content: content,
|
|
ArticleURL: "https://example.com/" + guid,
|
|
Source: "Example Wire",
|
|
Channel: "tech",
|
|
Classified: true,
|
|
SeenAt: nowUnix(),
|
|
}
|
|
if err := InsertStory(s); err != nil {
|
|
t.Fatalf("insert %s: %v", guid, err)
|
|
}
|
|
var id int64
|
|
if err := Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, guid).Scan(&id); err != nil {
|
|
t.Fatalf("lookup %s: %v", guid, err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func TestStoryViews_TotalsAndTrending(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
a := insertClassified(t, "s-a", "Story A", "some body text here")
|
|
b := insertClassified(t, "s-b", "Story B", "")
|
|
c := insertClassified(t, "s-c", "Story C", "another body")
|
|
|
|
// A read three times, C twice, B never.
|
|
RecordStoryView(a)
|
|
RecordStoryView(a)
|
|
RecordStoryView(a)
|
|
RecordStoryView(c)
|
|
RecordStoryView(c)
|
|
|
|
totals := StoryViewTotals([]int64{a, b, c})
|
|
if totals[a] != 3 {
|
|
t.Errorf("totals[a] = %d, want 3", totals[a])
|
|
}
|
|
if _, ok := totals[b]; ok {
|
|
t.Errorf("totals[b] present = %v, want absent (zero views)", totals[b])
|
|
}
|
|
if totals[c] != 2 {
|
|
t.Errorf("totals[c] = %d, want 2", totals[c])
|
|
}
|
|
|
|
// Trending over the last week: A (3) before C (2); B is absent (no views).
|
|
trend, err := TrendingStories(10, unixDay()-6)
|
|
if err != nil {
|
|
t.Fatalf("trending: %v", err)
|
|
}
|
|
if len(trend) != 2 {
|
|
t.Fatalf("trending len = %d, want 2 (B has no views)", len(trend))
|
|
}
|
|
if trend[0].ID != a || trend[1].ID != c {
|
|
t.Errorf("trending order = [%d, %d], want [%d, %d]", trend[0].ID, trend[1].ID, a, c)
|
|
}
|
|
|
|
// A window that starts after today excludes everything.
|
|
future, err := TrendingStories(10, unixDay()+1)
|
|
if err != nil {
|
|
t.Fatalf("trending future: %v", err)
|
|
}
|
|
if len(future) != 0 {
|
|
t.Errorf("trending (future window) len = %d, want 0", len(future))
|
|
}
|
|
}
|
|
|
|
func TestStoryContentLengths(t *testing.T) {
|
|
setupTestDB(t)
|
|
|
|
a := insertClassified(t, "c-a", "Has body", "hello world body")
|
|
b := insertClassified(t, "c-b", "No body", "")
|
|
|
|
lengths := StoryContentLengths([]int64{a, b})
|
|
if lengths[a] != len("hello world body") {
|
|
t.Errorf("lengths[a] = %d, want %d", lengths[a], len("hello world body"))
|
|
}
|
|
if _, ok := lengths[b]; ok {
|
|
t.Errorf("lengths[b] present, want absent (empty content)")
|
|
}
|
|
if got := StoryContentLengths(nil); len(got) != 0 {
|
|
t.Errorf("StoryContentLengths(nil) = %v, want empty", got)
|
|
}
|
|
}
|