diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js
index e2eaba8..99765af 100644
--- a/psf-memo-client/acceptance/lib/handlers.js
+++ b/psf-memo-client/acceptance/lib/handlers.js
@@ -28,6 +28,9 @@ const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
+const RecentFeedPage = require('../../src/services/recent-feed-page')
+const ProfilePage = require('../../src/services/profile-page')
+const ThreadPage = require('../../src/services/thread-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
@@ -37,6 +40,11 @@ const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
+// Placeholder addresses used by Gherkin steps that refer to 'a second address'
+// or 'a third address'.
+const SECOND_ADDRESS = 'bitcoincash:second-address'
+const THIRD_ADDRESS = 'bitcoincash:third-address'
+
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
function makeWallet (address) {
const wallet = {
@@ -85,6 +93,36 @@ function makeThread () {
}
}
+// A fake psf-memo-db API backing the read-only feed, profile, and thread
+// pages used to verify like count display.
+function makeMemoDb () {
+ const posts = []
+ const threads = {}
+
+ return {
+ posts,
+ threads,
+ addPost (post) {
+ posts.push(post)
+ },
+ addThread (txid, thread) {
+ threads[txid] = thread
+ },
+ async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
+ const page = posts.slice(offset, offset + limit)
+ return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
+ },
+ async getPostsByAddr (addr, { limit = 100, offset = 0 } = {}) {
+ const filtered = posts.filter((p) => p.addr === addr)
+ const page = filtered.slice(offset, offset + limit)
+ return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } }
+ },
+ async getPostThread (txid) {
+ return threads[txid] || { post: null }
+ }
+ }
+}
+
// Fresh world/state object for a single scenario execution.
function createWorld () {
const wallet = makeWallet('')
@@ -93,6 +131,7 @@ function createWorld () {
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
+ const memoDb = makeMemoDb()
const world = {
wallet,
@@ -101,11 +140,17 @@ function createWorld () {
memoPost,
memoReply,
memoLike,
+ memoDb,
currentPath: null,
menuOpen: false,
likedTxids: new Set()
}
+ // Read-only page controllers backed by the fake psf-memo-db API.
+ world.recentFeedPage = new RecentFeedPage({ memoDb })
+ world.profilePage = new ProfilePage({ memoDb })
+ world.threadPage = new ThreadPage({ memoDb })
+
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
world.newPage = new NewPostPage({
@@ -167,6 +212,20 @@ function resolveParam (value, example) {
return String(value).trim()
}
+// Look up a post that has been loaded onto one of the read-only pages.
+function findDisplayedPost (txid, world) {
+ const fromThread = world.threadPage.getPost(txid)
+ if (fromThread) return fromThread
+
+ const fromProfile = world.profilePage.getPost(txid)
+ if (fromProfile) return fromProfile
+
+ const fromFeed = world.recentFeedPage.getPost(txid)
+ if (fromFeed) return fromFeed
+
+ return null
+}
+
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
@@ -328,10 +387,11 @@ const handlers = [
{
name: 'open reply thread',
pattern: /^I open the thread for the post with txid (.+)$/,
- run (m, example, world) {
- const txid = m[1].trim()
+ async run (m, example, world) {
+ const txid = resolveParam(m[1], example)
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
+ await world.threadPage.load(txid)
}
},
{
@@ -796,10 +856,129 @@ const handlers = [
throw new Error('Expected like/tip modal to be closed.')
}
}
+ },
+ {
+ name: 'API serves post with explicit address and like count',
+ pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const addr = resolveParam(m[2], example)
+ const likeCount = parseInt(resolveParam(m[3], example), 10)
+ world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
+ }
+ },
+ {
+ name: 'API serves post with explicit address and no like count',
+ pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with no like count recorded$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const addr = resolveParam(m[2], example)
+ world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
+ }
+ },
+ {
+ name: 'API serves post with second/third address and like count',
+ pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with a like count of (.+)$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
+ const likeCount = parseInt(resolveParam(m[3], example), 10)
+ world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
+ }
+ },
+ {
+ name: 'API serves post with second/third address and no like count',
+ pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with no like count recorded$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
+ world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
+ }
+ },
+ {
+ name: 'open recent posts feed',
+ pattern: /^I open the recent posts feed$/,
+ async run (m, example, world) {
+ await world.recentFeedPage.load()
+ world.currentPath = RecentFeedPage.RECENT_FEED_PATH
+ }
+ },
+ {
+ name: 'open profile page for author',
+ pattern: /^I open the profile page for the author of the post with txid (.+)$/,
+ async run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const post = world.memoDb.posts.find((p) => p.txid === txid)
+ if (!post) {
+ throw new Error(`No API post found for txid ${txid}.`)
+ }
+ world.profilePage.addr = post.addr
+ await world.profilePage.load()
+ world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(post.addr)}`
+ }
+ },
+ {
+ name: 'thread contains a reply',
+ pattern: /^the thread for the post with txid (.+) contains a reply with txid (.+)$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const replyTxid = resolveParam(m[2], example)
+ const expectedReplyCount = parseInt(example.expected_reply, 10)
+ const rootPost = world.memoDb.posts.find((p) => p.txid === txid)
+ if (!rootPost) {
+ throw new Error(`No API post found for txid ${txid}.`)
+ }
+ const thread = {
+ post: {
+ ...rootPost,
+ replies: [{
+ txid: replyTxid,
+ addr: 'bitcoincash:reply-author',
+ text: 'A sample reply',
+ likeCount: Number.isNaN(expectedReplyCount) ? 0 : expectedReplyCount,
+ blockHeight: rootPost.blockHeight + 1,
+ replies: []
+ }]
+ }
+ }
+ world.memoDb.addThread(txid, thread)
+ }
+ },
+ {
+ name: 'post shows like count',
+ pattern: /^the post with txid (.+) shows the like count (.+)$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const expected = parseInt(resolveParam(m[2], example), 10)
+ const post = findDisplayedPost(txid, world)
+ if (!post) {
+ throw new Error(`Post ${txid} is not displayed.`)
+ }
+ const actual = post.likeCount ?? 0
+ if (actual !== expected) {
+ throw new Error(`Expected like count ${expected} for ${txid}, got ${actual}.`)
+ }
+ }
+ },
+ {
+ name: 'reply shows like count',
+ pattern: /^the reply with txid (.+) shows the like count (.+)$/,
+ run (m, example, world) {
+ const txid = resolveParam(m[1], example)
+ const expected = parseInt(resolveParam(m[2], example), 10)
+ const post = world.threadPage.getPost(txid)
+ if (!post) {
+ throw new Error(`Reply ${txid} is not displayed.`)
+ }
+ const actual = post.likeCount ?? 0
+ if (actual !== expected) {
+ throw new Error(`Expected like count ${expected} for reply ${txid}, got ${actual}.`)
+ }
+ }
}
]
-// Route a single step to its handler. Throws on unsupported step text.
+// Route a single step to its handler. Throws on unsupported step text. Throws on unsupported step text.
async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
diff --git a/psf-memo-client/src/components/app-body/profile/index.js b/psf-memo-client/src/components/app-body/profile/index.js
index cfad9db..f7a0b1f 100644
--- a/psf-memo-client/src/components/app-body/profile/index.js
+++ b/psf-memo-client/src/components/app-body/profile/index.js
@@ -9,6 +9,7 @@ import Jdenticon from '@chris.troutner/react-jdenticon'
import MemoDb from '../../../services/memo-db'
import PostReplyCount from '../../post-reply-count'
+import LikeButton from '../../post-feed/like-button'
import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
import './profile.css'
@@ -154,10 +155,13 @@ function Profile (props) {
Block {post.blockHeight}
{post.text}
- openThread(post.txid)}
- />
+
+
+
openThread(post.txid)}
+ />
+
))}
diff --git a/psf-memo-client/src/components/post-feed/like-button.js b/psf-memo-client/src/components/post-feed/like-button.js
index ff28eb7..18db219 100644
--- a/psf-memo-client/src/components/post-feed/like-button.js
+++ b/psf-memo-client/src/components/post-feed/like-button.js
@@ -12,7 +12,7 @@ import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons'
import './post-feed.css'
-function LikeButton ({ count = 0, liked = false, onClick }) {
+function LikeButton ({ count = 0, liked = false, onClick, readOnly = false }) {
const label = count === 1 ? '1 like' : `${count} likes`
const icon = liked ? faHeartSolid : faHeartRegular
const className = [
@@ -20,6 +20,19 @@ function LikeButton ({ count = 0, liked = false, onClick }) {
liked ? 'post-like-button-liked' : ''
].filter(Boolean).join(' ')
+ if (readOnly) {
+ return (
+
+
+ {count}
+
+ )
+ }
+
return (