From 3f5c8f809affc4afaefdc97d0b7d5c1549e0399f Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 19:48:37 -0700 Subject: [PATCH] Display like counts on feed, profile, and thread - Add testable RecentFeedPage, ProfilePage, and ThreadPage controllers. - Wire acceptance handlers for the like-count-display feature. - Render like counts on the profile page with a read-only LikeButton. - Add unit tests for the new display page services. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 185 +++++++++++++++++- .../src/components/app-body/profile/index.js | 12 +- .../src/components/post-feed/like-button.js | 15 +- psf-memo-client/src/services/profile-page.js | 45 +++++ .../src/services/recent-feed-page.js | 41 ++++ psf-memo-client/src/services/thread-page.js | 51 +++++ .../test/unit/profile-page.test.js | 49 +++++ .../test/unit/recent-feed-page.test.js | 67 +++++++ psf-memo-client/test/unit/thread-page.test.js | 86 ++++++++ 9 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 psf-memo-client/src/services/profile-page.js create mode 100644 psf-memo-client/src/services/recent-feed-page.js create mode 100644 psf-memo-client/src/services/thread-page.js create mode 100644 psf-memo-client/test/unit/profile-page.test.js create mode 100644 psf-memo-client/test/unit/recent-feed-page.test.js create mode 100644 psf-memo-client/test/unit/thread-page.test.js 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 (