Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Ownable, proper sort by timestamp #8

Merged
merged 11 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 61 additions & 19 deletions api/p/memeland/memeland.gno
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package memeland

import (
"gno.land/p/demo/avl"
"gno.land/p/demo/seqid"
"sort"
"std"
"strconv"
"strings"
"time"

"gno.land/p/demo/avl"
"gno.land/p/demo/ownable"
"gno.land/p/demo/seqid"
)

// Post - as previously defined
type Post struct {
ID string
Data string
Expand All @@ -20,15 +21,15 @@ type Post struct {
}

type Memeland struct {
*ownable.Ownable
Posts []*Post
MemeCounter seqid.ID
}

type UpvoteSorter []*Post

func NewMemeland() *Memeland {
return &Memeland{
Posts: make([]*Post, 0),
Ownable: ownable.New(),
Posts: make([]*Post, 0),
}
}

Expand Down Expand Up @@ -70,7 +71,7 @@ func (m *Memeland) Upvote(id string) string {
return "upvote successful"
}

// GetPostsInRange returns a JSON string of posts within the given timestamp range, supporting pagination.
// GetPostsInRange returns a JSON string of posts within the given timestamp range, supporting pagination
func (m *Memeland) GetPostsInRange(startTimestamp, endTimestamp int64, page, pageSize int, sortBy string) string {
if len(m.Posts) == 0 {
return "[]"
Expand All @@ -80,10 +81,16 @@ func (m *Memeland) GetPostsInRange(startTimestamp, endTimestamp int64, page, pag
panic("page count cannot be less than 1")
}

// No empty pages
if pageSize < 1 {
panic("page size cannot be less than 1")
}

// No pages larger than 10
if pageSize > 10 {
panic("page size cannot be larger than 10")
}

var filteredPosts []*Post

start := time.Unix(startTimestamp, 0)
Expand All @@ -99,10 +106,22 @@ func (m *Memeland) GetPostsInRange(startTimestamp, endTimestamp int64, page, pag
switch sortBy {
// Sort by upvote descending
case "UPVOTES":
sort.Sort(UpvoteSorter(filteredPosts))
dateSorter := PostSorter{
Posts: filteredPosts,
LessF: func(i, j int) bool {
return filteredPosts[i].UpvoteTracker.Size() > filteredPosts[j].UpvoteTracker.Size()
},
}
sort.Sort(dateSorter)
default:
// Sort by timestamp, beginning with newest
filteredPosts = reversePosts(filteredPosts)
dateSorter := PostSorter{
Posts: filteredPosts,
LessF: func(i, j int) bool {
return filteredPosts[i].Timestamp.Before(filteredPosts[j].Timestamp)
},
}
sort.Sort(dateSorter)
}

// Pagination
Expand All @@ -123,7 +142,24 @@ func (m *Memeland) GetPostsInRange(startTimestamp, endTimestamp int64, page, pag
return PostsToJSONString(filteredPosts[startIndex:endIndex])
}

// PostsToJSONString converts a slice of Post structs into a JSON string representation.
// RemovePost allows the owner to remove a post with a specific ID
func (m *Memeland) RemovePost(id string) string {
if err := m.CallerIsOwner(); err != nil {
panic(err)
}

posts := m.Posts
for i, post := range posts {
if post.ID == id {
posts = append(posts[:i], posts[i+1:]...)
return id
}
}

panic("post with specified id does not exist")
}

// PostsToJSONString converts a slice of Post structs into a JSON string
func PostsToJSONString(posts []*Post) string {
var sb strings.Builder
sb.WriteString("[")
Expand All @@ -140,6 +176,7 @@ func PostsToJSONString(posts []*Post) string {
return sb.String()
}

// PostToJSONString returns a Post formatted as a JSON string
func PostToJSONString(post *Post) string {
var sb strings.Builder

Expand Down Expand Up @@ -169,15 +206,20 @@ func (m *Memeland) getPost(id string) *Post {
return nil
}

func reversePosts(posts []*Post) []*Post {
for i, j := 0, len(posts)-1; i < j; i, j = i+1, j-1 {
posts[i], posts[j] = posts[j], posts[i]
}
return posts
// PostSorter is a flexible sorter for the *Post slice
type PostSorter struct {
Posts []*Post
LessF func(i, j int) bool
}

func (p PostSorter) Len() int {
return len(p.Posts)
}

func (p PostSorter) Swap(i, j int) {
p.Posts[i], p.Posts[j] = p.Posts[j], p.Posts[i]
}

func (a UpvoteSorter) Len() int { return len(a) }
func (a UpvoteSorter) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a UpvoteSorter) Less(i, j int) bool {
return a[i].UpvoteTracker.Size() > a[j].UpvoteTracker.Size()
func (p PostSorter) Less(i, j int) bool {
return p.LessF(i, j)
}
153 changes: 72 additions & 81 deletions api/p/memeland/memeland_test.gno
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,20 @@ func TestGetPostsInRangePagination(t *testing.T) {
m := NewMemeland()
now := time.Now()

numOfPosts := 10
numOfPosts := 5
var memeData []string
for i := 1; i <= numOfPosts; i++ {
// Prepare meme data
nextTime := now.Add(time.Duration(-i) * time.Minute)
nextTime := now.Add(time.Duration(i) * time.Minute)
data := ufmt.Sprintf("Meme #%d", i)
memeData = append(memeData, data)

m.PostMeme(data, nextTime.Unix())
}

// Get timestamps
beforeEarliest := now.Add(-time.Minute * time.Duration(numOfPosts+1)) // subtract 3 + 1 min from now
afterLatest := now.Add(time.Minute) // add 1 min to now
beforeEarliest := now.Add(-1 * time.Minute)
afterLatest := now.Add(time.Duration(numOfPosts)*time.Minute + time.Minute)

testCases := []struct {
page int
Expand All @@ -47,7 +47,7 @@ func TestGetPostsInRangePagination(t *testing.T) {
{page: 1, pageSize: numOfPosts, expectedNumOfPosts: numOfPosts}, // all posts on single page
{page: 12, pageSize: 1, expectedNumOfPosts: 0}, // empty page
{page: 1, pageSize: numOfPosts + 1, expectedNumOfPosts: numOfPosts}, // page with fewer posts than its size
{page: 5, pageSize: numOfPosts / 5, expectedNumOfPosts: 2}, // evenly distribute posts per page
{page: 5, pageSize: numOfPosts / 5, expectedNumOfPosts: 1}, // evenly distribute posts per page
}

for _, tc := range testCases {
Expand All @@ -70,25 +70,25 @@ func TestGetPostsInRangeByTimestamp(t *testing.T) {
numOfPosts := 5
var memeData []string
for i := 1; i <= numOfPosts; i++ {
// Prep meme data
nextTime := now.Add(time.Duration(-i) * time.Minute)
// Prepare meme data
nextTime := now.Add(time.Duration(i) * time.Minute)
data := ufmt.Sprintf("Meme #%d", i)
memeData = append(memeData, data)

m.PostMeme(data, nextTime.Unix())
}

// Get timestamps
beforeEarliest := now.Add(-time.Minute * time.Duration(numOfPosts+1)) // subtract 3 + 1 min from now
afterLatest := now.Add(time.Minute) // add 1 min to now
beforeEarliest := now.Add(-1 * time.Minute)
afterLatest := now.Add(time.Duration(numOfPosts)*time.Minute + time.Minute)

// Default sort is by addition order/timestamp
jsonStr := m.GetPostsInRange(
beforeEarliest.Unix(), // start at earliest post
afterLatest.Unix(), // end at latest post
1, // first page
numOfPosts, // all memes on the page
"DATE_CREATED", // do not sort by upvote
"DATE_CREATED", // sort by newest first
)

if jsonStr == "" {
Expand All @@ -101,101 +101,69 @@ func TestGetPostsInRangeByTimestamp(t *testing.T) {
t.Errorf("Expected %d posts in the JSON string, but found %d", m.MemeCounter, postCount)
}

// Check if data is there
for _, expData := range memeData {
if !strings.Contains(jsonStr, expData) {
t.Errorf("Expected %s in the JSON string, but counld't find it", expData)
}
}

// todo add test to check order
// Check if ordering is correct, sort by created date
for i := 0; i < len(memeData)-2; i++ {
if strings.Index(jsonStr, memeData[i]) > strings.Index(jsonStr, memeData[i+1]) {
t.Errorf("Expected %s to be before %s, but was at %d, and %d", memeData[i], memeData[i+1], i, i+1)
}
}
}

func TestGetPostsInRangeByUpvote(t *testing.T) {
m := NewMemeland()

// Create posts at specific times for testing
now := time.Now()
oneHourAgo := now.Add(-time.Hour)
twoHoursAgo := now.Add(-2 * time.Hour)
threeHoursAgo := now.Add(-3 * time.Hour)

// Add posts at specific timestamps
id1 := m.PostMeme("Meme #1", oneHourAgo.Unix())
id2 := m.PostMeme("Meme #2", twoHoursAgo.Unix())
memeData1 := "Meme #1"
memeData2 := "Meme #2"

// Create posts at specific times for testing
id1 := m.PostMeme(memeData1, now.Unix())
id2 := m.PostMeme(memeData2, now.Add(time.Minute).Unix())

m.Upvote(id1)
m.Upvote(id2)

// Change caller so avoid double upvote panic
std.TestSetOrigCaller(testutils.TestAddress("alice"))
m.Upvote(id2)
m.Upvote(id1)

// Final upvote count:
// Meme #1 - 1 upvote
// Meme #2 - 2 upvotes
// Meme #1 - 2 upvote
// Meme #2 - 1 upvotes

// Define test cases for different time ranges and pagination scenarios
testCases := []struct {
name string
startTimestamp int64
endTimestamp int64
page int
pageSize int
expectedCount int
expectedData []string
}{
{
name: "Upvote Sorted All Posts",
startTimestamp: threeHoursAgo.Add(-1 * time.Minute).Unix(), // Slightly before threeHoursAgo
endTimestamp: now.Unix(),
page: 1,
pageSize: 10,
expectedCount: 2,
expectedData: []string{"Meme #1", "Meme #2"},
},
{
name: "Upvote Sorted Pagination Test",
startTimestamp: twoHoursAgo.Add(-1 * time.Minute).Unix(),
endTimestamp: now.Unix(),
page: 1,
pageSize: 1, // Only request one post per page
expectedCount: 1,
expectedData: []string{"Meme #2"},
},
{
name: "Upvote Sorted Pagination Test: Second Post",
startTimestamp: threeHoursAgo.Unix(),
endTimestamp: now.Unix(),
page: 2,
pageSize: 1,
expectedCount: 1,
expectedData: []string{"Meme #1"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Sort by Upvote descending
jsonStr := m.GetPostsInRange(tc.startTimestamp, tc.endTimestamp, tc.page, tc.pageSize, "UPVOTES")
// Get timestamps
beforeEarliest := now.Add(-time.Minute)
afterLatest := now.Add(time.Hour)

if jsonStr == "" {
t.Error("Expected non-empty JSON string, got empty string")
}
// Default sort is by addition order/timestamp
jsonStr := m.GetPostsInRange(
beforeEarliest.Unix(), // start at earliest post
afterLatest.Unix(), // end at latest post
1, // first page
2, // all memes on the page
"UPVOTE", // sort by upvote
)

// Count the number of posts returned in the JSON string as a rudimentary check for correct pagination/filtering
postCount := strings.Count(jsonStr, `"id":"`)
if postCount != tc.expectedCount {
t.Errorf("Expected %d posts in the JSON string, but found %d", tc.expectedCount, postCount)
}
if jsonStr == "" {
t.Error("Expected non-empty JSON string, got empty string")
}

for _, expData := range tc.expectedData {
if !strings.Contains(jsonStr, expData) {
t.Errorf("Expected %s in the JSON string, but counld't find it", expData)
}
}
// Count the number of posts returned in the JSON string as a rudimentary check for correct pagination/filtering
postCount := strings.Count(jsonStr, `"id":"`)
if postCount != m.MemeCounter {
t.Errorf("Expected %d posts in the JSON string, but found %d", m.MemeCounter, postCount)
}

// todo add test to check order
})
// Check if ordering is correct
if strings.Index(jsonStr, "Meme #1") > strings.Index(jsonStr, "Meme #2") {
t.Errorf("Expected %s to be before %s", memeData1, memeData2)
}
}

Expand All @@ -210,7 +178,6 @@ func TestNoPosts(t *testing.T) {
if jsonStr != "[]" {
t.Errorf("Expected 0 posts to return [], got %s", jsonStr)
}

}

func TestUpvote(t *testing.T) {
Expand Down Expand Up @@ -240,3 +207,27 @@ func TestUpvote(t *testing.T) {
t.Errorf("Expected upvotes to be 1 after upvoting, got %d", post.UpvoteTracker.Size())
}
}

func TestDelete(t *testing.T) {
alice := testutils.TestAddress("alice")
std.TestSetOrigCaller(alice)

m := NewMemeland()

// Add a post to Memeland
now := time.Now()
postID := m.PostMeme("Meme #1", now.Unix())

// Bob will try to delete meme posted by Alice
bob := testutils.TestAddress("bob")
std.TestSetOrigCaller(bob)

defer func() {
if r := recover(); r == nil {
t.Errorf("code did not panic when in should have")
}
}()

// Should panic - caught by defer
m.RemovePost(postID)
}
Loading