Skip to content

Commit

Permalink
feat: Add Ownable, proper sort by timestamp (#8)
Browse files Browse the repository at this point in the history
* add ownable, proper time sort

* make tests better

* add remove post test

* add handler for panic in RemovePost

* formatting

* remove comments, move types around

* fix nit

* Revert "fix nit"

This reverts commit cca7ff0.

* Revert "Revert "fix nit""

This reverts commit 7a5df33.

* fix sorter, add godoc comments

* order imports
  • Loading branch information
leohhhn committed Mar 19, 2024
1 parent 3d8182f commit 9a26d92
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 101 deletions.
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

0 comments on commit 9a26d92

Please sign in to comment.