Merge architect branch for page-size-50

Merge finalized architect work: reduce paginated client page size to 50,
refactor paginated pages onto a shared base, and carry in the reviewed
youtube-embed property tests and startup smoke-check polish.

By specifier.
This commit is contained in:
Chris Troutner
2026-09-04 10:52:28 -07:00
31 changed files with 1199 additions and 70 deletions
+76
View File
@@ -0,0 +1,76 @@
# Review: page-size-50
**By architect.**
## Task and commits reviewed
- Task: `page-size-50` — reduce the client page size from 100 to 50 across all paginated
pages (recent feed, following feed, topic feed, notifications, search, profile, recent
profiles) to cut payload size and reduce page load times, plus add pagination controls and
full acceptance coverage.
- Inbound handoff: refactorer `a9488d9ae2` (fast-forward merge onto `swarmforge-architect`).
- Reviewed commits: `81cedab` (spec), `59cce442`/merge `38f7a42` (implementation by coder),
`a9488d9` (refactorer — PaginatedPage base + property coverage), and `0dba837` (briefing/
backlog update after the youtube-embed merge).
- Scope note: the fast-forward also carried the prior `0dba837` specifier/backlog update;
it is informational only, no code.
## Architectural findings and fixes applied
- **Good UI/Core separation:** page services remain pure, testable controllers with the MemoDb
client injected. The coder's change was a one-line default (`limit = 100``limit = 50`)
in each `load`/`submit` plus new `canLoadMore()` methods on the four pages that lacked them.
- **Refactorer DRY extraction:** extracted a shared `PaginatedPage` base (new
`src/services/paginated-page.js`) and subclassed `RecentFeedPage` and `RecentProfilesPage`
onto it, removing the duplicated load/canLoadMore pattern. Dependency direction is correct:
the concrete pages depend on the pure base; the base has no IO. Each subclass keeps a thin
custom item-finder (`getPost`/`getProfile`). Clean, cohesive, well-hidden. No structural
changes required.
- **Hardening (this review):** killed the surviving `canLoadMore` mutation in the base and the
three pages that added `canLoadMore` without tests (profile, search, topic-feed), plus
`notifications-page`, all of which had the same uncovered `?? false` default-on-null edge:
- Added new `test/unit/paginated-page.test.js` (6 tests) covering load storage, the
missing-client guard, the 50/0 defaults, and `canLoadMore` under null / missing-`hasMore`
pagination.
- Added `canLoadMore` null-pagination tests to `notifications`, `profile`, `search`, and
`topic-feed` unit test files.
- **Documented equivalent (not chased):** the `notifications-page.js` constructor
`this.empty = false → true` mutation survives because `load()` always overwrites `empty`
before it is read (component and acceptance both read it after `load`). Genuine equivalent.
- **Noted, not refactored:** the pagination Previous/Next UI is duplicated across the profile,
recent-profiles, and search components. Extracting a shared pagination component is a broad
cross-module UI refactor beyond this handoff; the controller-side dedup via `PaginatedPage`
is the appropriate scope here.
## Verification results
- **Language mutation** (`mutate4javascript <file> --max-workers 8 --mutate-all`, sequential):
- `paginated-page.js` Killed 5 / 0 survived / 0 uncovered
- `recent-feed-page.js` Killed 1 / 0 / 0
- `recent-profiles-page.js` Killed 1 / 0 / 0
- `following-feed-page.js` Killed 12 / 0 / 0
- `notifications-page.js` Killed 11 / 1 survived / 0 uncovered (the constructor `empty`
equivalent documented above)
- `profile-page.js` Killed 22 / 0 / 0
- `search-page.js` Killed 9 / 0 / 0
- `topic-feed-page.js` Killed 15 / 0 / 0
- `memo-db.js` is excluded from mutation testing (ESM + `../config` directory import,
standing precedent).
- **DRY** (`dry4javascript`) on all touched services: no duplicate candidates.
- **Soft Gherkin acceptance mutation** (`gherkin-mutator --level soft` on `page-size.feature`):
0 killed, 24 survived. All 24 are single-character case/value mutations of setup example
values — counts that stay above 50 and addresses/topics/queries/txids used consistently on
both sides of their scenario. Intrinsic equivalents for a read-only feature; not chased.
- **Unit tests** (`npm test`): 280 passing (267 prior + 13 hardening), 0 fail.
- **Property tests** (`npm run test:property`): 40 passing (37 prior + 3 new
`paginated-page` properties), 0 fail.
- **Acceptance** (`npm run test:acceptance`): all 22 generated suites PASS, including the new
`page-size` scenarios.
- **Lint:** clean. **Build:** OK.
## Suite status
- psf-memo-client: 280 unit, 40 property, acceptance PASS, lint clean, build OK.
## Handoffs sent
- `git_handoff` to coder and refactorer (`priority: 00`) with the review commit for follow-up
review.
- No functional commit for the specifier (the task produced a functional client change, but
the specifier-authored spec `81cedab` is already merged and there is no new feature to
specify; the handoff chain ends at architect + coder/refactorer review).
+46
View File
@@ -48,3 +48,49 @@
## Handoffs sent
- `git_handoff` to coder and refactorer (`priority: 00`) with the review commit for follow-up
review.
---
# Follow-up review: property tests (batch 20260904T164750Z)
**By architect.**
## Task and commits reviewed
- Follow-up on `youtube-embed`: refactorer added property tests pinning down the parser's
invariants over broad random inputs.
- Inbound handoff: refactorer `9ec8760b59` (merged onto `swarmforge-architect`).
- Reviewed commit: `9ec8760b59` — new `psf-memo-client/test/property/youtube-embed.property.test.js`
(119 lines, 4 properties). No source change.
## Architectural findings
- **Good test placement:** property tests live in `test/property/`, separate from unit tests,
per the constitution (property tests are not part of normal unit coverage, mutation, CRAP,
or Gherkin mutation). They are run via the dedicated `npm run test:property` command.
- **Sound invariants:** the four properties (round-trip reconstruction, segment shape with
URL-safe video ids, non-YouTube URLs staying text, and video-id round trips) are meaningful
and exercise the parser's URL splitting and trailing-punctuation handling over a token pool
that mixes words, YouTube links, non-YouTube links, and punctuation.
- **Deterministic:** uses the shared seeded `harness.js` PRNG (seed 20260904), so runs are
reproducible. Tests run sequentially under node:test, so the shared rng stream is stable.
- No structural or boundary issues; no changes required.
## Verification results
- **Property tests** (`npm run test:property`): 37 passing (33 prior + 4 new), 0 fail.
- **Language mutation** (`mutate4javascript src/services/youtube-embed.js --max-workers 8 --mutate-all`):
Killed 6, Survived 1, Uncovered 0. The sole survivor (`line 82 1 -> 0` in `parsePostText`,
`match[1]``match[0]`) is the previously documented genuine equivalent (`URL_RE`'s capture
group spans the whole pattern). Property tests are excluded from mutation coverage by design.
- **DRY** (`dry4javascript src/services/youtube-embed.js`): no duplicate candidates.
- **Soft Gherkin acceptance mutation** (`gherkin-mutator --level soft` on `youtube-embed.feature`):
7 killed, 18 survived. All 18 survivors are single-character case/value mutations of example
values (addresses, txids, text, URLs) used consistently on both the setup and assertion sides
of their scenarios — intrinsic equivalents for a read-only feature, not implementation gaps.
- **Unit tests** (`npm test`): 260 passing, 0 fail. **Lint:** clean.
## Suite status
- psf-memo-client: 260 unit passing, 37 property passing, lint clean. No source change, so
acceptance behavior is unchanged from the prior review.
## Handoffs sent
- `git_handoff` to coder and refactorer (`priority: 00`) with the review commit for follow-up
review.
+273 -15
View File
@@ -42,6 +42,7 @@ const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const NotificationsPage = require('../../src/services/notifications-page')
const RecentProfilesPage = require('../../src/services/recent-profiles-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -296,7 +297,7 @@ function makeMemoDb () {
}
return addrs
},
async search (q) {
async search (q, { limit = 50, offset = 0 } = {}) {
const normalized = String(q).trim().toLowerCase()
if (normalized.length === 0) {
return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } }
@@ -308,18 +309,19 @@ function makeMemoDb () {
(typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) ||
(typeof p.text === 'string' && p.text.toLowerCase().includes(normalized))
)
const total = matchedPosts.length + matchedProfiles.length
const total = matchedPosts.length
const page = matchedPosts.slice(offset, offset + limit)
return {
posts: matchedPosts,
posts: page,
profiles: matchedProfiles,
pagination: { total, hasMore: false }
pagination: { total, limit, offset, hasMore: offset + page.length < total }
}
},
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
async getRecentPosts ({ limit = 50, 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 } = {}) {
async getPostsByAddr (addr, { limit = 50, 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 } }
@@ -341,12 +343,12 @@ function makeMemoDb () {
list.sort((a, b) => a.room.localeCompare(b.room))
return { topics: list }
},
async getTopicPosts (room, { limit = 100, offset = 0 } = {}) {
async getTopicPosts (room, { limit = 50, offset = 0 } = {}) {
const all = topicPosts[room] || []
const page = all.slice(offset, offset + limit)
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
},
async getNotifications (addr, { limit = 100, offset = 0 } = {}) {
async getNotifications (addr, { limit = 50, offset = 0 } = {}) {
const notifications = []
for (const reply of replies) {
@@ -392,7 +394,11 @@ function makeMemoDb () {
const page = notifications.slice(offset, offset + limit)
return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } }
},
async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) {
async getRecentProfiles ({ limit = 50, offset = 0 } = {}) {
const page = profiles.slice(offset, offset + limit)
return { profiles: page, pagination: { total: profiles.length, limit, offset, hasMore: offset + page.length < profiles.length } }
},
async getFollowingFeed (addr, { limit = 50, offset = 0 } = {}) {
const followees = new Set()
for (const [key, following] of Object.entries(followState)) {
if (!following) continue
@@ -410,7 +416,7 @@ function makeMemoDb () {
// Fresh world/state object for a single scenario execution.
function createWorld () {
const wallet = makeWallet('')
const wallet = makeWallet('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d')
const feed = makeFeed()
const profiles = makeProfiles()
const memoPost = new MemoPost({ wallet, feed })
@@ -456,6 +462,7 @@ function createWorld () {
memoDb,
navigate: (path) => { world.currentPath = path }
})
world.recentProfilesPage = new RecentProfilesPage({ 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.
@@ -2632,13 +2639,264 @@ const handlers = [
}
},
{
name: 'API serves post with address and text',
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+)$/,
name: 'API serves N recent posts',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) recent posts$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.addPost({
txid: `recent-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `Recent post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N posts by address',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) posts authored by the address (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const addr = resolveParam(m[2], example)
const text = resolveText(m[3], example)
world.memoDb.addPost({ txid, addr, text, blockHeight: 100 })
for (let i = 0; i < count; i++) {
world.memoDb.addPost({
txid: `${addr}-post-${i + 1}`.padEnd(64, '0'),
addr,
text: `Post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N posts in topic',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) posts in the topic (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const room = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addTopicPost(room, {
txid: `${room}-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `Topic post ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N replies to post',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) replies to the post with txid (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const parentTxid = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addReply({
txid: `reply-${i + 1}`.padEnd(64, '0'),
parentTxid,
text: `Reply ${i + 1}`,
addr: 'bitcoincash:reply-author',
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N search posts',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) search posts matching (.+)$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
const query = resolveParam(m[2], example)
for (let i = 0; i < count; i++) {
world.memoDb.addSearchPost({
txid: `search-post-${i + 1}`.padEnd(64, '0'),
addr: `addr-${i + 1}`,
text: `${query} search result ${i + 1}`,
blockHeight: 100 + i
})
}
}
},
{
name: 'API serves N profiles',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) profiles$/,
run (m, example, world) {
const count = parseInt(resolveParam(m[1], example), 10)
for (let i = 0; i < count; i++) {
world.memoDb.profiles.push({
addr: `profile-addr-${i + 1}`,
text: `Profile ${i + 1}`,
txid: `profile-txid-${i + 1}`.padEnd(64, '0'),
blockHeight: 100 + i,
seen: Date.now()
})
}
}
},
{
name: 'recent feed shows 50 posts',
pattern: /^the recent feed shows 50 posts$/,
run (m, example, world) {
const actual = world.recentFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in recent feed, got ${actual}.`)
}
}
},
{
name: 'recent feed can load more posts',
pattern: /^the recent feed can load more posts$/,
run (m, example, world) {
if (!world.recentFeedPage.canLoadMore || !world.recentFeedPage.canLoadMore()) {
throw new Error('Expected recent feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'following feed shows 50 posts',
pattern: /^the following feed shows 50 posts$/,
run (m, example, world) {
const actual = world.followingFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in following feed, got ${actual}.`)
}
}
},
{
name: 'following feed can load more posts',
pattern: /^the following feed can load more posts$/,
run (m, example, world) {
if (!world.followingFeedPage.canLoadMore()) {
throw new Error('Expected following feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'topic feed shows 50 posts',
pattern: /^the topic feed shows 50 posts$/,
run (m, example, world) {
const actual = world.topicFeedPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in topic feed, got ${actual}.`)
}
}
},
{
name: 'topic feed can load more posts',
pattern: /^the topic feed can load more posts$/,
run (m, example, world) {
if (!world.topicFeedPage.canLoadMore()) {
throw new Error('Expected topic feed to have more posts, but pagination says there are none.')
}
}
},
{
name: 'notifications show 50 notifications',
pattern: /^the notifications show 50 notifications$/,
run (m, example, world) {
const actual = world.notificationsPage.notifications.length
if (actual !== 50) {
throw new Error(`Expected 50 notifications, got ${actual}.`)
}
}
},
{
name: 'search results show 50 posts',
pattern: /^the search results show 50 posts$/,
run (m, example, world) {
const actual = world.searchPage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts in search results, got ${actual}.`)
}
}
},
{
name: 'search results can load more posts',
pattern: /^the search results can load more posts$/,
run (m, example, world) {
if (!world.searchPage.canLoadMore || !world.searchPage.canLoadMore()) {
throw new Error('Expected search results to have more pages, but pagination says there are none.')
}
}
},
{
name: 'profile page shows 50 posts',
pattern: /^the profile page shows 50 posts$/,
run (m, example, world) {
const actual = world.profilePage.posts.length
if (actual !== 50) {
throw new Error(`Expected 50 posts on profile page, got ${actual}.`)
}
}
},
{
name: 'profile page can load more posts',
pattern: /^the profile page can load more posts$/,
run (m, example, world) {
if (!world.profilePage.canLoadMore || !world.profilePage.canLoadMore()) {
throw new Error('Expected profile page to have more posts, but pagination says there are none.')
}
}
},
{
name: 'recent profiles page shows 50 profiles',
pattern: /^the recent profiles page shows 50 profiles$/,
run (m, example, world) {
const actual = world.recentProfilesPage.profiles.length
if (actual !== 50) {
throw new Error(`Expected 50 profiles on recent profiles page, got ${actual}.`)
}
}
},
{
name: 'recent profiles page can load more profiles',
pattern: /^the recent profiles page can load more profiles$/,
run (m, example, world) {
if (!world.recentProfilesPage.canLoadMore()) {
throw new Error('Expected recent profiles page to have more profiles, but pagination says there are none.')
}
}
},
{
name: 'open recent profiles page',
pattern: /^I open the recent profiles page$/,
async run (m, example, world) {
await world.recentProfilesPage.load()
world.currentPath = RecentProfilesPage.RECENT_PROFILES_PATH
}
},
{
name: 'open profile page for address',
pattern: /^I open the profile page for the address (.+)$/,
async run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr,
myAddr,
memoFollow: world.memoFollow,
memoMute: world.memoMute
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
},
{
name: 'open topic feed for topic',
pattern: /^I open the topic feed for (.+)$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.topicFeedPage = new TopicFeedPage({
memoDb: world.memoDb,
room,
myAddr,
memoTopicFollow: world.memoTopicFollow
})
await world.topicFeedPage.load()
world.currentPath = TopicFeedPage.topicFeedPath(room)
}
},
{
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-04T17:42:23.821120012Z","feature_name":"Page Size","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/page-size.feature","background_hash":"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Page Size - 1, Page Size - 2, Page Size - 3, Page Size - 4, Page Size - 5, Page Size - 6, Page Size - 7
#
# Every paginated page in the client requests 50 items per page instead of 100.
@@ -18,7 +18,7 @@ import {
import '../../../App.css'
import '../../post-feed/post-feed.css'
const PAGE_SIZE = 100
const PAGE_SIZE = 50
function FollowingFeed (props) {
const { appData } = props
@@ -12,7 +12,7 @@ import MemoDb from '../../../services/memo-db'
import NotificationsPage from '../../../services/notifications-page'
import '../../../App.css'
const PAGE_SIZE = 100
const PAGE_SIZE = 50
function notificationText (n) {
if (n.type === 'reply') {
@@ -17,7 +17,7 @@ import {
import '../../../App.css'
import '../../post-feed/post-feed.css'
const PAGE_SIZE = 100
const PAGE_SIZE = 50
function RecentPosts (props) {
const { appData } = props
@@ -18,6 +18,8 @@ import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
import './profile.css'
const PAGE_SIZE = 50
function formatSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
@@ -64,6 +66,7 @@ function Profile (props) {
const [showThreadModal, setShowThreadModal] = useState(false)
const [profiles, setProfiles] = useState({})
const [profilePage, setProfilePage] = useState(null)
const [offset, setOffset] = useState(0)
const [busy, setBusy] = useState(false)
const openThread = (txid) => {
@@ -114,7 +117,7 @@ function Profile (props) {
const [profile, profilePic, pageData] = await Promise.all([
memoDb.getProfile(addr),
memoDb.getProfilePic(addr),
page.load()
page.load({ limit: PAGE_SIZE, offset })
])
setProfileText(profile?.text || '')
@@ -136,13 +139,24 @@ function Profile (props) {
setError('Missing profile address')
setLoading(false)
}
}, [addr, myAddr, wallet, appProfiles])
}, [addr, myAddr, wallet, appProfiles, offset])
const showFollowButton = profilePage && profilePage.canFollow() && !profilePage.isFollowing()
const showUnfollowButton = profilePage && profilePage.canFollow() && profilePage.isFollowing()
const showMuteButton = profilePage && profilePage.canMute() && !profilePage.isMuting()
const showUnmuteButton = profilePage && profilePage.canMute() && profilePage.isMuting()
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const handlePrevious = () => {
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
}
const handleNext = () => {
setOffset((prev) => prev + PAGE_SIZE)
}
return (
<Container fluid className='profile-page mt-4'>
{error && <p className='text-danger'>{error}</p>}
@@ -222,7 +236,7 @@ function Profile (props) {
<h2 className='profile-posts-title'>Posts</h2>
{pagination && (
<span className='text-muted'>
{pagination.total} post{pagination.total === 1 ? '' : 's'}
{pagination.offset + 1}{pagination.offset + posts.length} of {pagination.total} posts
</span>
)}
</div>
@@ -249,6 +263,26 @@ function Profile (props) {
</Card.Body>
</Card>
))}
{!loading && !error && (pagination || offset > 0) && (
<div className='profile-posts-pagination mt-3'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
)}
@@ -4,14 +4,16 @@
import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Container, Row, Col, Spinner, Table } from 'react-bootstrap'
import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import RecentProfilesPage from '../../../services/recent-profiles-page'
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
import '../../../App.css'
const appUtil = new AppUtil()
const PAGE_SIZE = 50
function formatSeen (seen) {
if (!seen) return ''
@@ -24,22 +26,39 @@ function RecentProfiles () {
const [error, setError] = useState(null)
const [profiles, setProfiles] = useState([])
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
useEffect(() => {
const loadProfiles = async () => {
try {
setLoading(true)
setError(null)
const memoDb = new MemoDb()
const data = await memoDb.getRecentProfiles({ limit: 100, offset: 0 })
const page = new RecentProfilesPage({ memoDb })
const data = await page.load({ limit: PAGE_SIZE, offset })
setProfiles(data.profiles || [])
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load recent profiles')
setProfiles([])
setPagination(null)
}
setLoading(false)
}
loadProfiles()
}, [])
}, [offset])
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const handlePrevious = () => {
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
}
const handleNext = () => {
setOffset((prev) => prev + PAGE_SIZE)
}
return (
<Container>
@@ -102,6 +121,26 @@ function RecentProfiles () {
</tbody>
</Table>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='recent-profiles-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
</Container>
@@ -12,6 +12,8 @@ import MemoDb from '../../../services/memo-db'
import SearchPage from '../../../services/search-page'
import '../../../App.css'
const PAGE_SIZE = 50
function SearchResults (props) {
const { posts, profiles, searched } = props
@@ -64,6 +66,8 @@ function Search (props) {
const [error, setError] = useState(null)
const [posts, setPosts] = useState([])
const [profiles, setProfiles] = useState([])
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
const [searched, setSearched] = useState(false)
const handleSubmit = async (event) => {
@@ -71,23 +75,57 @@ function Search (props) {
setLoading(true)
setError(null)
setSearched(true)
setOffset(0)
try {
const memoDb = new MemoDb()
const page = new SearchPage({ memoDb })
page.setQuery(query)
const result = await page.submit()
const result = await page.submit({ limit: PAGE_SIZE, offset: 0 })
setPosts(result.posts || [])
setProfiles(result.profiles || [])
setPagination(result.pagination || null)
} catch (err) {
setError(err.message || 'Search failed')
setPosts([])
setProfiles([])
setPagination(null)
}
setLoading(false)
}
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const loadPage = async (nextOffset) => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const page = new SearchPage({ memoDb })
page.setQuery(query)
const result = await page.submit({ limit: PAGE_SIZE, offset: nextOffset })
setPosts(result.posts || [])
setProfiles(result.profiles || [])
setPagination(result.pagination || null)
setOffset(nextOffset)
} catch (err) {
setError(err.message || 'Search failed')
}
setLoading(false)
}
const handlePreviousPage = () => {
loadPage(Math.max(0, offset - PAGE_SIZE))
}
const handleNextPage = () => {
loadPage(offset + PAGE_SIZE)
}
return (
<Container className='search-page'>
<Row className='justify-content-center'>
@@ -123,6 +161,26 @@ function Search (props) {
)}
{!loading && <SearchResults posts={posts} profiles={profiles} searched={searched} />}
{!loading && searched && (pagination || offset > 0) && (
<div className='search-pagination mt-3'>
<Button
variant='outline-dark'
onClick={handlePreviousPage}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNextPage}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
</Container>
@@ -24,7 +24,7 @@ import { byteLength } from '../../../services/utf8'
import '../../../App.css'
import '../../post-feed/post-feed.css'
const PAGE_SIZE = 100
const PAGE_SIZE = 50
function TopicFeed (props) {
const { appData } = props
@@ -22,7 +22,7 @@ class FollowingFeedPage {
return this.wallet?.walletInfo?.cashAddress || null
}
async load ({ limit = 100, offset = 0 } = {}) {
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Following feed page requires a memo db client.')
}
@@ -54,5 +54,5 @@ FollowingFeedPage.FOLLOWING_FEED_PATH = FOLLOWING_FEED_PATH
module.exports = FollowingFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-02T19:04:51.797Z","module_hash":"7754a521f2e17d3d290ecca2dd644364ec96585a123a9e5601154d85c3a60548","functions":[{"id":"func/FollowingFeedPage.constructor","name":"FollowingFeedPage.constructor","line":13,"end_line":19,"hash":"015246f47e903cfc98029de709dbf4b0c0a47999c2ad737acfa3f46c17d66381"},{"id":"func/FollowingFeedPage.getMyAddress","name":"FollowingFeedPage.getMyAddress","line":21,"end_line":23,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/FollowingFeedPage.load","name":"FollowingFeedPage.load","line":25,"end_line":41,"hash":"c8e64d2a4fcd714aff9fc23893e1d313c63c0c6b8048149d4d89772772938cca"},{"id":"func/FollowingFeedPage.canLoadMore","name":"FollowingFeedPage.canLoadMore","line":43,"end_line":45,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/FollowingFeedPage.getPost","name":"FollowingFeedPage.getPost","line":47,"end_line":49,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-09-04T17:10:16.507Z","module_hash":"e30a0befd791a2a6adfc48239339ec7655dfdce56699653d4c7d85b6a39cedfd","functions":[{"id":"func/FollowingFeedPage.constructor","name":"FollowingFeedPage.constructor","line":13,"end_line":19,"hash":"015246f47e903cfc98029de709dbf4b0c0a47999c2ad737acfa3f46c17d66381"},{"id":"func/FollowingFeedPage.getMyAddress","name":"FollowingFeedPage.getMyAddress","line":21,"end_line":23,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/FollowingFeedPage.load","name":"FollowingFeedPage.load","line":25,"end_line":41,"hash":"f61377da944c0bc716d03ef0ad97c4c62d0aeb1b2b47788093ededa7f58545f5"},{"id":"func/FollowingFeedPage.canLoadMore","name":"FollowingFeedPage.canLoadMore","line":43,"end_line":45,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/FollowingFeedPage.getPost","name":"FollowingFeedPage.getPost","line":47,"end_line":49,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
+4 -4
View File
@@ -10,11 +10,11 @@ class MemoDb {
this.axios = axios
}
async getRecentProfiles ({ limit = 100, offset = 0 } = {}) {
async getRecentProfiles ({ limit = 50, offset = 0 } = {}) {
return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset })
}
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
async getRecentPosts ({ limit = 50, offset = 0 } = {}) {
return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset })
}
@@ -58,7 +58,7 @@ class MemoDb {
return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers')
}
async search (q, { limit = 100, offset = 0 } = {}) {
async search (q, { limit = 50, offset = 0 } = {}) {
try {
const result = await this.axios.get(`${config.backend}/search`, {
params: { q, limit, offset }
@@ -108,7 +108,7 @@ class MemoDb {
}
// GET a paginated resource page at a full path.
async getPage (path, name, { limit = 100, offset = 0 } = {}) {
async getPage (path, name, { limit = 50, offset = 0 } = {}) {
try {
const result = await this.axios.get(`${config.backend}${path}`, {
params: { limit, offset }
@@ -22,7 +22,7 @@ class NotificationsPage {
return this.wallet?.walletInfo?.cashAddress || null
}
async load ({ limit = 100, offset = 0 } = {}) {
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Notifications page requires a memo db client.')
}
@@ -58,5 +58,5 @@ NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
module.exports = NotificationsPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-02T20:18:43.400Z","module_hash":"647bc4fca66811725670506e32f4db2a94b387b4d318a658a925490607b2caa7","functions":[{"id":"func/NotificationsPage.constructor","name":"NotificationsPage.constructor","line":13,"end_line":19,"hash":"52eca9540d0f0c524cd8b2c6c2a80c37cc1769c2a109bae7c97f080bede56130"},{"id":"func/NotificationsPage.getMyAddress","name":"NotificationsPage.getMyAddress","line":21,"end_line":23,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/NotificationsPage.load","name":"NotificationsPage.load","line":25,"end_line":45,"hash":"99ce6b9176f964e8794325b59c7e67318514b64388117df930e5addb8ece7ea5"},{"id":"func/NotificationsPage.canLoadMore","name":"NotificationsPage.canLoadMore","line":47,"end_line":49,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/NotificationsPage.getNotification","name":"NotificationsPage.getNotification","line":51,"end_line":53,"hash":"d01238c7fddb85e9ca7427d512c828ec6524657feb1156a820516641ed27105c"}]}
// {"version":1,"tested_at":"2026-09-04T17:20:15.460Z","module_hash":"302d668c6360824911c04786d5337002fe2870aed064fdb3ba85b3944e6c4f7b","functions":[{"id":"func/NotificationsPage.constructor","name":"NotificationsPage.constructor","line":13,"end_line":19,"hash":"52eca9540d0f0c524cd8b2c6c2a80c37cc1769c2a109bae7c97f080bede56130"},{"id":"func/NotificationsPage.getMyAddress","name":"NotificationsPage.getMyAddress","line":21,"end_line":23,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/NotificationsPage.load","name":"NotificationsPage.load","line":25,"end_line":45,"hash":"d99ba2df1db799b969e7fc4b8e3f0964c84f973d92e048df97c361e4659b0c9d"},{"id":"func/NotificationsPage.canLoadMore","name":"NotificationsPage.canLoadMore","line":47,"end_line":49,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/NotificationsPage.getNotification","name":"NotificationsPage.getNotification","line":51,"end_line":53,"hash":"d01238c7fddb85e9ca7427d512c828ec6524657feb1156a820516641ed27105c"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,41 @@
/*
Shared base for simple paginated page controllers.
A paginated page loads a single list from a MemoDb method, stores the list
and its pagination, and reports whether more items can be loaded. Subclasses
supply the memoDb method name, the result list field, and the error message
for the missing-client guard, then add their own item finder.
*/
class PaginatedPage {
constructor (deps = {}, { listField, loadMethod, errorMessage }) {
this.memoDb = deps.memoDb || null
this[listField] = []
this.pagination = null
this._listField = listField
this._loadMethod = loadMethod
this._errorMessage = errorMessage
}
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error(this._errorMessage)
}
const data = await this.memoDb[this._loadMethod]({ limit, offset })
this[this._listField] = data[this._listField] || []
this.pagination = data.pagination || null
return { [this._listField]: this[this._listField], pagination: this.pagination }
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
module.exports = PaginatedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-04T17:08:23.730Z","module_hash":"6ba99f826fd1a2bd4a090bedaabe3a8497d4ebe3694138b26090fa5c2f1f6efb","functions":[{"id":"func/PaginatedPage.constructor","name":"PaginatedPage.constructor","line":11,"end_line":18,"hash":"14d764e7220271146f33020326072e2ebb653e1a1372360348e552056d21e0c8"},{"id":"func/PaginatedPage.load","name":"PaginatedPage.load","line":20,"end_line":30,"hash":"b9c43d761b25e202dfbc16beee7ab90b06ee75dcb9ba0799ee014d513b2e9a5c"},{"id":"func/PaginatedPage.canLoadMore","name":"PaginatedPage.canLoadMore","line":32,"end_line":34,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"}]}
// mutate4javascript-manifest-end
+6 -2
View File
@@ -26,7 +26,7 @@ class ProfilePage {
this.muteState = null
}
async load ({ limit = 100, offset = 0 } = {}) {
async load ({ limit = 50, offset = 0 } = {}) {
this._assertReady()
const data = await this.memoDb.getPostsByAddr(this.addr, { limit, offset })
@@ -123,6 +123,10 @@ class ProfilePage {
getPost (txid) {
return this.posts.find((post) => post.txid === txid) || null
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
ProfilePage.PROFILE_PATH_PREFIX = PROFILE_PATH_PREFIX
@@ -130,5 +134,5 @@ ProfilePage.PROFILE_PATH_PREFIX = PROFILE_PATH_PREFIX
module.exports = ProfilePage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-29T03:10:07.663Z","module_hash":"be54fde7e97be7c83df6680f33b31151f5f2f91835ba404705c988c0baa84e6a","functions":[{"id":"func/ProfilePage.constructor","name":"ProfilePage.constructor","line":17,"end_line":27,"hash":"2a9265aa29bd66a11c2a627ecad5149b093f53b0892b848bef09ac2b949a2ee2"},{"id":"func/ProfilePage.load","name":"ProfilePage.load","line":29,"end_line":45,"hash":"c57316a8a92684b9506f340122361f69a7361f0190c4e8a0e7816a11de2db089"},{"id":"func/ProfilePage._assertReady","name":"ProfilePage._assertReady","line":48,"end_line":55,"hash":"e575f98fa1b492b4c11ae5275459d9f9996df4c566b84c7cae91413c72a02f38"},{"id":"func/ProfilePage._loadState","name":"ProfilePage._loadState","line":59,"end_line":64,"hash":"ab0376720ac75e6fd66d437fbb47a53176d76f681cb13b8794249f2a586549e8"},{"id":"func/ProfilePage.isOwnProfile","name":"ProfilePage.isOwnProfile","line":66,"end_line":68,"hash":"4d149bfe7b183b5d38d496c0a8126d22bd9a919e684f375d66e2c8c1348dd773"},{"id":"func/ProfilePage.canFollow","name":"ProfilePage.canFollow","line":70,"end_line":72,"hash":"0bc2c0c17991d8f1a1d18fba29c901e42633b7cf2c2c8b5747a49e5832d7001d"},{"id":"func/ProfilePage.isFollowing","name":"ProfilePage.isFollowing","line":74,"end_line":76,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/ProfilePage.follow","name":"ProfilePage.follow","line":78,"end_line":80,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/ProfilePage.unfollow","name":"ProfilePage.unfollow","line":82,"end_line":84,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/ProfilePage._setFollowState","name":"ProfilePage._setFollowState","line":87,"end_line":89,"hash":"6cd937862bd6bf18d63fe767724b02ca23d1dc54f71ede23b84b9bde2930200b"},{"id":"func/ProfilePage.canMute","name":"ProfilePage.canMute","line":91,"end_line":93,"hash":"0076ea0e688df292dbe6bcde370aa32937cf7dad674e2168c3395d41b63850af"},{"id":"func/ProfilePage.isMuting","name":"ProfilePage.isMuting","line":95,"end_line":97,"hash":"c4fe9ea0501b7349da39321afd074e7ae48c2c1f2e0d1cfafafbf57f8a05c754"},{"id":"func/ProfilePage.mute","name":"ProfilePage.mute","line":99,"end_line":101,"hash":"228ff6bb19d81e92f99090b6242043b216b9ff8dd9f7aa49d4e50521b1270c26"},{"id":"func/ProfilePage.unmute","name":"ProfilePage.unmute","line":103,"end_line":105,"hash":"ea3e7cbf5a02898ea2a924fb42b683ef50a7a9985ac9118dd404e46fcaa78ed1"},{"id":"func/ProfilePage._setMuteState","name":"ProfilePage._setMuteState","line":108,"end_line":110,"hash":"721536335442a539a8ec7700d9db79abd18cfc24ed451fcd29e198f531d0896f"},{"id":"func/ProfilePage._setState","name":"ProfilePage._setState","line":114,"end_line":121,"hash":"8e01237a2cf48ca49a5c480d94a89cd6fbe3911bd476b17ca826a21bcbd60f4c"},{"id":"func/ProfilePage.getPost","name":"ProfilePage.getPost","line":123,"end_line":125,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-09-04T17:36:49.028Z","module_hash":"0a1de2d4dc847a50df7d6d1bec9899d61dbe0856a069651f1794384af66aede9","functions":[{"id":"func/ProfilePage.constructor","name":"ProfilePage.constructor","line":17,"end_line":27,"hash":"2a9265aa29bd66a11c2a627ecad5149b093f53b0892b848bef09ac2b949a2ee2"},{"id":"func/ProfilePage.load","name":"ProfilePage.load","line":29,"end_line":45,"hash":"d38c4bec56cac83f0df4975852d915bb6bffc4c566cbcca3a933f31b340b6125"},{"id":"func/ProfilePage._assertReady","name":"ProfilePage._assertReady","line":48,"end_line":55,"hash":"e575f98fa1b492b4c11ae5275459d9f9996df4c566b84c7cae91413c72a02f38"},{"id":"func/ProfilePage._loadState","name":"ProfilePage._loadState","line":59,"end_line":64,"hash":"ab0376720ac75e6fd66d437fbb47a53176d76f681cb13b8794249f2a586549e8"},{"id":"func/ProfilePage.isOwnProfile","name":"ProfilePage.isOwnProfile","line":66,"end_line":68,"hash":"4d149bfe7b183b5d38d496c0a8126d22bd9a919e684f375d66e2c8c1348dd773"},{"id":"func/ProfilePage.canFollow","name":"ProfilePage.canFollow","line":70,"end_line":72,"hash":"0bc2c0c17991d8f1a1d18fba29c901e42633b7cf2c2c8b5747a49e5832d7001d"},{"id":"func/ProfilePage.isFollowing","name":"ProfilePage.isFollowing","line":74,"end_line":76,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/ProfilePage.follow","name":"ProfilePage.follow","line":78,"end_line":80,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/ProfilePage.unfollow","name":"ProfilePage.unfollow","line":82,"end_line":84,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/ProfilePage._setFollowState","name":"ProfilePage._setFollowState","line":87,"end_line":89,"hash":"6cd937862bd6bf18d63fe767724b02ca23d1dc54f71ede23b84b9bde2930200b"},{"id":"func/ProfilePage.canMute","name":"ProfilePage.canMute","line":91,"end_line":93,"hash":"0076ea0e688df292dbe6bcde370aa32937cf7dad674e2168c3395d41b63850af"},{"id":"func/ProfilePage.isMuting","name":"ProfilePage.isMuting","line":95,"end_line":97,"hash":"c4fe9ea0501b7349da39321afd074e7ae48c2c1f2e0d1cfafafbf57f8a05c754"},{"id":"func/ProfilePage.mute","name":"ProfilePage.mute","line":99,"end_line":101,"hash":"228ff6bb19d81e92f99090b6242043b216b9ff8dd9f7aa49d4e50521b1270c26"},{"id":"func/ProfilePage.unmute","name":"ProfilePage.unmute","line":103,"end_line":105,"hash":"ea3e7cbf5a02898ea2a924fb42b683ef50a7a9985ac9118dd404e46fcaa78ed1"},{"id":"func/ProfilePage._setMuteState","name":"ProfilePage._setMuteState","line":108,"end_line":110,"hash":"721536335442a539a8ec7700d9db79abd18cfc24ed451fcd29e198f531d0896f"},{"id":"func/ProfilePage._setState","name":"ProfilePage._setState","line":114,"end_line":121,"hash":"8e01237a2cf48ca49a5c480d94a89cd6fbe3911bd476b17ca826a21bcbd60f4c"},{"id":"func/ProfilePage.getPost","name":"ProfilePage.getPost","line":123,"end_line":125,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/ProfilePage.canLoadMore","name":"ProfilePage.canLoadMore","line":127,"end_line":129,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"}]}
// mutate4javascript-manifest-end
@@ -10,25 +10,17 @@
boundary.
*/
const PaginatedPage = require('./paginated-page')
const RECENT_FEED_PATH = '/posts/recent'
class RecentFeedPage {
class RecentFeedPage extends PaginatedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.posts = []
this.pagination = null
}
async load ({ limit = 100, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Recent feed page requires a memo db client.')
}
const data = await this.memoDb.getRecentPosts({ limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
return { posts: this.posts, pagination: this.pagination }
super(deps, {
listField: 'posts',
loadMethod: 'getRecentPosts',
errorMessage: 'Recent feed page requires a memo db client.'
})
}
getPost (txid) {
@@ -41,5 +33,5 @@ RecentFeedPage.RECENT_FEED_PATH = RECENT_FEED_PATH
module.exports = RecentFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T03:38:52.144Z","module_hash":"e2ee3beff9079c7f93d29043b1428c271fbd1a2b64f2635fbc2cc06fb52a3aab","functions":[{"id":"func/RecentFeedPage.constructor","name":"RecentFeedPage.constructor","line":16,"end_line":20,"hash":"0eb6faf1dfd73ad95b210473ab82fdae513ff1570e401820645661881a2f8d4b"},{"id":"func/RecentFeedPage.load","name":"RecentFeedPage.load","line":22,"end_line":32,"hash":"96e8f2c77aadf98e3a9e71e1f0f6ce98a3e47dd828ffb4626916f56ca32ed137"},{"id":"func/RecentFeedPage.getPost","name":"RecentFeedPage.getPost","line":34,"end_line":36,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-09-04T17:09:10.548Z","module_hash":"9c0c48d7b21d24fcf640bf461b9798b2377097687c433be64776495ab7166601","functions":[{"id":"func/RecentFeedPage.constructor","name":"RecentFeedPage.constructor","line":18,"end_line":24,"hash":"78adbb7be7ccdc136fb6bb9201a8447b0a484f525a7426f25f281b0ecd78c0ae"},{"id":"func/RecentFeedPage.getPost","name":"RecentFeedPage.getPost","line":26,"end_line":28,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,33 @@
/*
Recent Profiles Page behavior: load and display the most recent Memo profiles.
This is the testable controller behind the React "Recent Profiles" page. It
wraps the MemoDb client and exposes the loaded profiles and pagination so the
view can render them.
*/
const PaginatedPage = require('./paginated-page')
const RECENT_PROFILES_PATH = '/profile/recent'
class RecentProfilesPage extends PaginatedPage {
constructor (deps = {}) {
super(deps, {
listField: 'profiles',
loadMethod: 'getRecentProfiles',
errorMessage: 'Recent profiles page requires a memo db client.'
})
}
getProfile (addr) {
return this.profiles.find((profile) => profile.addr === addr) || null
}
}
RecentProfilesPage.RECENT_PROFILES_PATH = RECENT_PROFILES_PATH
module.exports = RecentProfilesPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-04T17:09:41.844Z","module_hash":"8a305e7c3c74e6ef33e57da9a0c1f3d3e0eff6ff2d6da99d1383d93874745b65","functions":[{"id":"func/RecentProfilesPage.constructor","name":"RecentProfilesPage.constructor","line":14,"end_line":20,"hash":"01a7876c30597e8eb8e37003290b8a9c9db2d3f2bc7305104e1b02659cc413c5"},{"id":"func/RecentProfilesPage.getProfile","name":"RecentProfilesPage.getProfile","line":22,"end_line":24,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]}
// mutate4javascript-manifest-end
+6 -2
View File
@@ -23,7 +23,7 @@ class SearchPage {
return this
}
async submit ({ limit = 100, offset = 0 } = {}) {
async submit ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Search page requires a memo db client.')
}
@@ -47,6 +47,10 @@ class SearchPage {
getProfile (addr) {
return this.profiles.find((profile) => profile.addr === addr) || null
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
SearchPage.SEARCH_PATH = SEARCH_PATH
@@ -54,5 +58,5 @@ SearchPage.SEARCH_PATH = SEARCH_PATH
module.exports = SearchPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-29T15:29:31.910Z","module_hash":"5ff867161b6e0af82435936924bfc3f2843e94b60354bbb39833714adb2a7448","functions":[{"id":"func/SearchPage.constructor","name":"SearchPage.constructor","line":12,"end_line":19,"hash":"6e48e42aae340ec24012bd86529d4493664e896d0d86a0bcfaf75e813d196da7"},{"id":"func/SearchPage.setQuery","name":"SearchPage.setQuery","line":21,"end_line":24,"hash":"45cdbb5a6cc4329f2ae2918130de995482f5587efdfa8bf48191672c0917cab3"},{"id":"func/SearchPage.submit","name":"SearchPage.submit","line":26,"end_line":41,"hash":"2e1394b99ec30f3b0fb867ab45c07c439c0d71ecef868dd37bfd7af11c584a2a"},{"id":"func/SearchPage.getPost","name":"SearchPage.getPost","line":43,"end_line":45,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/SearchPage.getProfile","name":"SearchPage.getProfile","line":47,"end_line":49,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]}
// {"version":1,"tested_at":"2026-09-04T17:38:44.927Z","module_hash":"0f319e8a75381449e4f3ff4077d58e963a21d186fa2fd2831b69305d7471b567","functions":[{"id":"func/SearchPage.constructor","name":"SearchPage.constructor","line":12,"end_line":19,"hash":"6e48e42aae340ec24012bd86529d4493664e896d0d86a0bcfaf75e813d196da7"},{"id":"func/SearchPage.setQuery","name":"SearchPage.setQuery","line":21,"end_line":24,"hash":"45cdbb5a6cc4329f2ae2918130de995482f5587efdfa8bf48191672c0917cab3"},{"id":"func/SearchPage.submit","name":"SearchPage.submit","line":26,"end_line":41,"hash":"a048678628fd9fa69c25138093a020ea6e17f8ccc891e97a2088daf573d7945d"},{"id":"func/SearchPage.getPost","name":"SearchPage.getPost","line":43,"end_line":45,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/SearchPage.getProfile","name":"SearchPage.getProfile","line":47,"end_line":49,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"},{"id":"func/SearchPage.canLoadMore","name":"SearchPage.canLoadMore","line":51,"end_line":53,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"}]}
// mutate4javascript-manifest-end
@@ -18,7 +18,7 @@ class TopicFeedPage {
this.followers = []
}
async load ({ limit = 100, offset = 0 } = {}) {
async load ({ limit = 50, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Topic feed page requires a memo db client.')
}
@@ -81,6 +81,10 @@ class TopicFeedPage {
getPost (txid) {
return this.posts.find((post) => post.txid === txid) || null
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
}
TopicFeedPage.topicFeedPath = function (room) {
@@ -90,5 +94,5 @@ TopicFeedPage.topicFeedPath = function (room) {
module.exports = TopicFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T18:24:48.504Z","module_hash":"236ec55785bb69ac64c4a610ef4a5f8a8b03fae7b717d79c14c7fd2e755775e6","functions":[{"id":"func/TopicFeedPage.constructor","name":"TopicFeedPage.constructor","line":10,"end_line":19,"hash":"e6ee69c9caa13fcc05c7739fc185767f906c907a3031cf2c02abaab9220daca5"},{"id":"func/TopicFeedPage.load","name":"TopicFeedPage.load","line":21,"end_line":36,"hash":"ef85636d06f2b1eb6afd19b276bd8b9728ee35122d4424b3faf468de4a0e23a4"},{"id":"func/TopicFeedPage._loadFollowState","name":"TopicFeedPage._loadFollowState","line":38,"end_line":43,"hash":"d1984bb94b4f2e7c98c39524fc2fd0080dc5cff10e775a17c68856c2e3a2c249"},{"id":"func/TopicFeedPage._loadFollowers","name":"TopicFeedPage._loadFollowers","line":45,"end_line":47,"hash":"8b19d3623b4a872f4abe879ceda7e4018edf60efff03d037fe795b6558222b37"},{"id":"func/TopicFeedPage.canFollow","name":"TopicFeedPage.canFollow","line":49,"end_line":51,"hash":"2f82c2096bf6f542b60cf20d82cd70f366652fcce3a38ff14e82164d5ee86cad"},{"id":"func/TopicFeedPage.isFollowing","name":"TopicFeedPage.isFollowing","line":53,"end_line":55,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/TopicFeedPage.follow","name":"TopicFeedPage.follow","line":57,"end_line":59,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/TopicFeedPage.unfollow","name":"TopicFeedPage.unfollow","line":61,"end_line":63,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/TopicFeedPage._setFollowState","name":"TopicFeedPage._setFollowState","line":65,"end_line":79,"hash":"698f22cdfc2c088446c732cf01864d8859b299e066c199eb542bc965413f48b4"},{"id":"func/TopicFeedPage.getPost","name":"TopicFeedPage.getPost","line":81,"end_line":83,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-09-04T17:39:50.423Z","module_hash":"fe12e7e5411d6dfbfcfc08d2526692abacf224fc2626d108658f9dee7b664914","functions":[{"id":"func/TopicFeedPage.constructor","name":"TopicFeedPage.constructor","line":10,"end_line":19,"hash":"e6ee69c9caa13fcc05c7739fc185767f906c907a3031cf2c02abaab9220daca5"},{"id":"func/TopicFeedPage.load","name":"TopicFeedPage.load","line":21,"end_line":36,"hash":"db9b1cf8bde02a61a16c9444b64e39c41519a9db1e77a4065229b24d6b2428b5"},{"id":"func/TopicFeedPage._loadFollowState","name":"TopicFeedPage._loadFollowState","line":38,"end_line":43,"hash":"d1984bb94b4f2e7c98c39524fc2fd0080dc5cff10e775a17c68856c2e3a2c249"},{"id":"func/TopicFeedPage._loadFollowers","name":"TopicFeedPage._loadFollowers","line":45,"end_line":47,"hash":"8b19d3623b4a872f4abe879ceda7e4018edf60efff03d037fe795b6558222b37"},{"id":"func/TopicFeedPage.canFollow","name":"TopicFeedPage.canFollow","line":49,"end_line":51,"hash":"2f82c2096bf6f542b60cf20d82cd70f366652fcce3a38ff14e82164d5ee86cad"},{"id":"func/TopicFeedPage.isFollowing","name":"TopicFeedPage.isFollowing","line":53,"end_line":55,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/TopicFeedPage.follow","name":"TopicFeedPage.follow","line":57,"end_line":59,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/TopicFeedPage.unfollow","name":"TopicFeedPage.unfollow","line":61,"end_line":63,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/TopicFeedPage._setFollowState","name":"TopicFeedPage._setFollowState","line":65,"end_line":79,"hash":"698f22cdfc2c088446c732cf01864d8859b299e066c199eb542bc965413f48b4"},{"id":"func/TopicFeedPage.getPost","name":"TopicFeedPage.getPost","line":81,"end_line":83,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/TopicFeedPage.canLoadMore","name":"TopicFeedPage.canLoadMore","line":85,"end_line":87,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,112 @@
/*
Property tests for the shared PaginatedPage base controller.
The unit tests probe a few fixed pagination shapes. These properties cover
the load/`canLoadMore`/lookup invariants over broad random inputs so they
hold everywhere for the recent feed and recent profiles pages:
- has more: canLoadMore always mirrors pagination.hasMore.
- forwarding: the load limit and offset forwarded to the memo-db client
match exactly what the caller requested.
- lookup: the item finder returns a loaded item by key and null otherwise.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const PaginatedPage = require('../../src/services/paginated-page')
const rng = seededRandom(20260904)
// A minimal concrete subclass exercising the shared base logic.
class TestPage extends PaginatedPage {
constructor (deps = {}) {
super(deps, {
listField: 'items',
loadMethod: 'getItems',
errorMessage: 'Test page requires a memo db client.'
})
}
getItem (key) {
return this.items.find((item) => item.key === key) || null
}
}
function makeMemoDb (items, pagination) {
return {
async getItems ({ limit, offset }) {
return { items, pagination }
}
}
}
function fixtureGen () {
return () => {
const n = intGen(rng, 0, 8)()
const items = []
for (let i = 0; i < n; i++) {
items.push({ key: 'item-' + i, text: 'value ' + i })
}
return {
items,
pagination: { hasMore: rng() < 0.5 },
limit: intGen(rng, 1, 100)(),
offset: intGen(rng, 0, 200)()
}
}
}
test('canLoadMore always mirrors pagination.hasMore', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const page = new TestPage({ memoDb: makeMemoDb(items, pagination) })
await page.load({ limit, offset })
return page.canLoadMore() === (pagination.hasMore === true) &&
page.pagination.hasMore === pagination.hasMore
},
{ label: 'paginated canLoadMore mirrors hasMore' }
)
})
test('load forwards exactly the requested limit and offset to the memo-db client', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const calls = []
const memoDb = {
async getItems (params) {
calls.push(params)
return { items, pagination }
}
}
const page = new TestPage({ memoDb })
await page.load({ limit, offset })
return calls.length === 1 &&
calls[0].limit === limit &&
calls[0].offset === offset
},
{ label: 'paginated load forwards limit and offset' }
)
})
test('getItem returns a loaded item by key, otherwise null', async () => {
await forAll(
fixtureGen(),
async ({ items, pagination, limit, offset }) => {
const page = new TestPage({ memoDb: makeMemoDb(items, pagination) })
await page.load({ limit, offset })
if (items.length === 0) return true
const any = page.getItem(items[0].key)
if (!any || any.text !== items[0].text) return false
return page.getItem('does-not-exist') === null
},
{ label: 'paginated getItem lookup' }
)
})
@@ -0,0 +1,119 @@
/*
Property tests for the YouTube embed parser.
The unit tests probe parsePostText / extractYouTubeVideoId at a few fixed
fixtures. These properties pin down the parser's invariants over broad
random inputs:
- Round trip: concatenating the segments (text for text segments, url for
youtube segments) reconstructs the original input exactly, including any
trailing punctuation that was stripped from a URL.
- Every youtube segment carries a non-empty, URL-safe video id.
- A URL that is not a youtube.com/watch or youtu.be link never becomes a
youtube segment; it stays plain text.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const {
extractYouTubeVideoId,
parsePostText
} = require('../../src/services/youtube-embed')
const rng = seededRandom(20260904)
const WATCH_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
const SHORT_URL = 'https://youtu.be/dQw4w9WgXcQ'
const OTHER_URL = 'https://example.com/video'
// A pool of tokens used to build random post text. Mixing words, YouTube
// links, non-YouTube links, and punctuation exercises the parser's URL
// splitting and trailing-punctuation handling.
const TOKENS = [
'hello', 'world', 'check', 'this', 'out', 'memo', 'post', 'a', 'the',
' ', ' ', '.', ',', '!', '?', ':', ';',
WATCH_URL, SHORT_URL, OTHER_URL,
'https://example.com/other/path?q=1',
'https://youtu.be/',
'https://www.youtube.com/watch?v='
]
function randomText () {
const n = intGen(rng, 0, 12)()
let text = ''
for (let i = 0; i < n; i++) {
text += TOKENS[Math.floor(rng() * TOKENS.length)]
}
return text
}
function reconstruct (segments) {
return segments
.map((s) => (s.type === 'youtube' ? s.url : s.text))
.join('')
}
test('parsePostText round-trips: segments reconstruct the original text', async () => {
await forAll(
() => randomText(),
async (text) => {
const segments = parsePostText(text)
return reconstruct(segments) === text
},
{ label: 'youtube-embed round trip', samples: 2000 }
)
})
test('parsePostText yields only text and youtube segments with valid video ids', async () => {
await forAll(
() => randomText(),
async (text) => {
const segments = parsePostText(text)
for (const segment of segments) {
if (segment.type !== 'text' && segment.type !== 'youtube') return false
if (segment.type === 'youtube') {
if (!segment.videoId) return false
if (!/^[A-Za-z0-9_-]+$/.test(segment.videoId)) return false
}
}
return true
},
{ label: 'youtube-embed segment shape', samples: 2000 }
)
})
test('parsePostText never turns a non-YouTube URL into a youtube segment', async () => {
await forAll(
() => randomText(),
async (text) => {
const segments = parsePostText(text)
for (const segment of segments) {
if (segment.type !== 'youtube') continue
// A youtube segment must have come from a youtube.com/watch or
// youtu.be URL, so its id must be extractable from that URL.
if (extractYouTubeVideoId(segment.url) !== segment.videoId) return false
}
return true
},
{ label: 'youtube-embed non-youtube stays text', samples: 2000 }
)
})
test('extractYouTubeVideoId round-trips a valid watch and short URL', async () => {
await forAll(
() => {
const id = 'id' + Math.floor(rng() * 1e9).toString(36)
const kind = Math.floor(rng() * 2)
return kind === 0
? `https://www.youtube.com/watch?v=${id}`
: `https://youtu.be/${id}`
},
async (url) => {
const id = url.split('v=')[1] || url.split('youtu.be/')[1]
return extractYouTubeVideoId(url) === id
},
{ label: 'youtube-embed id round trip', samples: 2000 }
)
})
@@ -91,7 +91,7 @@ test('load forwards limit and offset to the memo db client', async () => {
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }])
})
test('load defaults limit to 100 and offset to 0', async () => {
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async getFollowingFeed (addr, params) {
@@ -103,7 +103,7 @@ test('load defaults limit to 100 and offset to 0', async () => {
await page.load()
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }])
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 50, offset: 0 } }])
})
test('load throws when no memo db client is provided', async () => {
@@ -79,7 +79,7 @@ test('load forwards limit and offset to the memo db client', async () => {
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }])
})
test('load defaults limit to 100 and offset to 0', async () => {
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async getNotifications (addr, params) {
@@ -91,7 +91,7 @@ test('load defaults limit to 100 and offset to 0', async () => {
await page.load()
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }])
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 50, offset: 0 } }])
})
test('load throws when no memo db client is provided', async () => {
@@ -128,6 +128,22 @@ test('canLoadMore reflects pagination.hasMore', async () => {
assert.equal(pageDone.canLoadMore(), false)
})
test('canLoadMore returns false when pagination is null or missing hasMore', async () => {
const pageNull = new NotificationsPage({
memoDb: makeMemoDb([], null),
wallet: makeWallet()
})
await pageNull.load()
assert.equal(pageNull.canLoadMore(), false)
const pageEmpty = new NotificationsPage({
memoDb: makeMemoDb([], { total: 0 }),
wallet: makeWallet()
})
await pageEmpty.load()
assert.equal(pageEmpty.canLoadMore(), false)
})
test('getNotification returns a loaded notification by txid', async () => {
const notifications = [{ type: 'follow', txid: 'a'.repeat(64), addr: 'bitcoincash:other' }]
const page = new NotificationsPage({
@@ -0,0 +1,95 @@
/*
Unit tests for the shared PaginatedPage base controller.
The base class encapsulates the common load / canLoadMore pattern shared by
the recent feed and recent profiles page controllers. These tests exercise
the base directly, including the null-pagination edge case that the
subclass tests do not cover.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const PaginatedPage = require('../../src/services/paginated-page')
class TestPage extends PaginatedPage {
constructor (deps = {}) {
super(deps, {
listField: 'items',
loadMethod: 'getItems',
errorMessage: 'Test page requires a memo db client.'
})
}
getItem (key) {
return this.items.find((item) => item.key === key) || null
}
}
function makeMemoDb (items, pagination) {
return {
async getItems ({ limit, offset }) {
return { items, pagination }
}
}
}
test('load stores the list under the configured list field', async () => {
const items = [{ key: 'a' }, { key: 'b' }]
const page = new TestPage({ memoDb: makeMemoDb(items, { total: 2 }) })
const result = await page.load()
assert.deepEqual(result.items, items)
assert.deepEqual(page.items, items)
assert.equal(result.pagination.total, 2)
})
test('load throws when no memo db client is provided', async () => {
const page = new TestPage({})
await assert.rejects(
() => page.load(),
/requires a memo db client/
)
})
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async getItems (params) {
calls.push(params)
return { items: [], pagination: {} }
}
}
const page = new TestPage({ memoDb })
await page.load()
assert.deepEqual(calls, [{ limit: 50, offset: 0 }])
})
test('canLoadMore returns false when pagination is null', async () => {
const page = new TestPage({ memoDb: makeMemoDb([], null) })
await page.load()
assert.equal(page.canLoadMore(), false)
})
test('canLoadMore returns false when pagination has no hasMore field', async () => {
const page = new TestPage({ memoDb: makeMemoDb([], { total: 0 }) })
await page.load()
assert.equal(page.canLoadMore(), false)
})
test('canLoadMore reflects pagination.hasMore', async () => {
const more = new TestPage({ memoDb: makeMemoDb([], { hasMore: true }) })
await more.load()
assert.equal(more.canLoadMore(), true)
const done = new TestPage({ memoDb: makeMemoDb([], { hasMore: false }) })
await done.load()
assert.equal(done.canLoadMore(), false)
})
+52 -2
View File
@@ -75,7 +75,7 @@ test('load throws when no address is provided', async () => {
)
})
test('load defaults limit to 100 and offset to 0', async () => {
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const memoDb = {
@@ -91,7 +91,7 @@ test('load defaults limit to 100 and offset to 0', async () => {
await page.load()
assert.deepEqual(calls, [{ a: addr, params: { limit: 100, offset: 0 } }])
assert.deepEqual(calls, [{ a: addr, params: { limit: 50, offset: 0 } }])
})
test('load sets pagination to null when the API returns none', async () => {
@@ -184,3 +184,53 @@ test('follow throws when no memo follow handler is injected', async () => {
/requires a memo follow handler/
)
})
test('canLoadMore reflects pagination.hasMore', async () => {
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const more = new ProfilePage({
memoDb: {
async getPostsByAddr () { return { posts: [], pagination: { hasMore: true } } },
async getFollowState () { return false },
async getMuteState () { return false }
},
addr
})
await more.load()
assert.equal(more.canLoadMore(), true)
const done = new ProfilePage({
memoDb: {
async getPostsByAddr () { return { posts: [], pagination: { hasMore: false } } },
async getFollowState () { return false },
async getMuteState () { return false }
},
addr
})
await done.load()
assert.equal(done.canLoadMore(), false)
})
test('canLoadMore returns false when pagination is null or missing hasMore', async () => {
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const pageNull = new ProfilePage({
memoDb: {
async getPostsByAddr () { return { posts: [], pagination: null } },
async getFollowState () { return false },
async getMuteState () { return false }
},
addr
})
await pageNull.load()
assert.equal(pageNull.canLoadMore(), false)
const pageEmpty = new ProfilePage({
memoDb: {
async getPostsByAddr () { return { posts: [], pagination: { total: 0 } } },
async getFollowState () { return false },
async getMuteState () { return false }
},
addr
})
await pageEmpty.load()
assert.equal(pageEmpty.canLoadMore(), false)
})
@@ -66,7 +66,7 @@ test('load throws when no memo db client is provided', async () => {
)
})
test('load defaults limit to 100 and offset to 0', async () => {
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async getRecentPosts (params) {
@@ -78,5 +78,5 @@ test('load defaults limit to 100 and offset to 0', async () => {
await page.load()
assert.deepEqual(calls, [{ limit: 100, offset: 0 }])
assert.deepEqual(calls, [{ limit: 50, offset: 0 }])
})
@@ -0,0 +1,99 @@
/*
Unit tests for the recent profiles page controller.
The recent profiles page is a thin, testable wrapper around the MemoDb client.
It loads the paginated list of recent profiles and exposes each profile.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const RecentProfilesPage = require('../../src/services/recent-profiles-page')
function makeMemoDb (profiles, pagination) {
return {
async getRecentProfiles ({ limit, offset }) {
return { profiles, pagination }
}
}
}
test('load returns profiles and pagination', async () => {
const profiles = [
{ addr: 'bitcoincash:a', text: 'Alice' },
{ addr: 'bitcoincash:b', text: 'Bob' }
]
const page = new RecentProfilesPage({ memoDb: makeMemoDb(profiles, { total: 2 }) })
const result = await page.load()
assert.deepEqual(result.profiles, profiles)
assert.equal(result.pagination.total, 2)
})
test('load throws when no memo db client is provided', async () => {
const page = new RecentProfilesPage({})
await assert.rejects(
() => page.load(),
/requires a memo db client/
)
})
test('load forwards limit and offset to the memo db client', async () => {
const calls = []
const memoDb = {
async getRecentProfiles (params) {
calls.push(params)
return { profiles: [], pagination: {} }
}
}
const page = new RecentProfilesPage({ memoDb })
await page.load({ limit: 10, offset: 20 })
assert.deepEqual(calls, [{ limit: 10, offset: 20 }])
})
test('load defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async getRecentProfiles (params) {
calls.push(params)
return { profiles: [], pagination: {} }
}
}
const page = new RecentProfilesPage({ memoDb })
await page.load()
assert.deepEqual(calls, [{ limit: 50, offset: 0 }])
})
test('canLoadMore reflects pagination.hasMore', async () => {
const pageMore = new RecentProfilesPage({
memoDb: makeMemoDb([], { hasMore: true })
})
await pageMore.load()
assert.equal(pageMore.canLoadMore(), true)
const pageDone = new RecentProfilesPage({
memoDb: makeMemoDb([], { hasMore: false })
})
await pageDone.load()
assert.equal(pageDone.canLoadMore(), false)
})
test('getProfile returns a loaded profile by address', async () => {
const profiles = [{ addr: 'bitcoincash:a', text: 'Alice' }]
const page = new RecentProfilesPage({ memoDb: makeMemoDb(profiles, {}) })
await page.load()
assert.equal(page.getProfile('bitcoincash:a').text, 'Alice')
})
test('exposes the recent profiles path', () => {
assert.equal(RecentProfilesPage.RECENT_PROFILES_PATH, '/profile/recent')
})
+26 -2
View File
@@ -49,7 +49,7 @@ test('submit forwards query, limit and offset to the memo db client', async () =
assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }])
})
test('submit defaults limit to 100 and offset to 0', async () => {
test('submit defaults limit to 50 and offset to 0', async () => {
const calls = []
const memoDb = {
async search (q, params) {
@@ -62,7 +62,7 @@ test('submit defaults limit to 100 and offset to 0', async () => {
page.setQuery('memo')
await page.submit()
assert.deepEqual(calls, [{ q: 'memo', params: { limit: 100, offset: 0 } }])
assert.deepEqual(calls, [{ q: 'memo', params: { limit: 50, offset: 0 } }])
})
test('submit throws when no memo db client is provided', async () => {
@@ -128,3 +128,27 @@ test('getProfile returns null when no profile matches', async () => {
assert.equal(page.getProfile('addr2'), null)
})
test('canLoadMore reflects pagination.hasMore', async () => {
const more = new SearchPage({ memoDb: makeMemoDb([], [], { hasMore: true }) })
more.setQuery('hello')
await more.submit()
assert.equal(more.canLoadMore(), true)
const done = new SearchPage({ memoDb: makeMemoDb([], [], { hasMore: false }) })
done.setQuery('hello')
await done.submit()
assert.equal(done.canLoadMore(), false)
})
test('canLoadMore returns false when pagination is null or missing hasMore', async () => {
const pageNull = new SearchPage({ memoDb: makeMemoDb([], [], null) })
pageNull.setQuery('hello')
await pageNull.submit()
assert.equal(pageNull.canLoadMore(), false)
const pageEmpty = new SearchPage({ memoDb: makeMemoDb([], [], { total: 0 }) })
pageEmpty.setQuery('hello')
await pageEmpty.submit()
assert.equal(pageEmpty.canLoadMore(), false)
})
@@ -71,7 +71,7 @@ test('load forwards limit and offset to the memo db client', async () => {
assert.deepEqual(calls, [{ room: 'bitcoin', params: { limit: 10, offset: 20 } }])
})
test('load defaults limit and offset', async () => {
test('load defaults limit and offset to 50 and 0', async () => {
const calls = []
const memoDb = {
async getTopicPosts (room, params) {
@@ -89,7 +89,7 @@ test('load defaults limit and offset', async () => {
await page.load()
assert.deepEqual(calls, [{ limit: 100, offset: 0 }])
assert.deepEqual(calls, [{ limit: 50, offset: 0 }])
})
test('stores the pagination returned by the memo db client', async () => {
@@ -226,3 +226,23 @@ test('follow requires a memo topic follow handler', async () => {
test('exposes the topic feed path for a room', () => {
assert.equal(TopicFeedPage.topicFeedPath('bitcoin'), '/topics/bitcoin')
})
test('canLoadMore reflects pagination.hasMore', async () => {
const more = new TopicFeedPage({ memoDb: makeMemoDb([], { hasMore: true }), room: 'bitcoin' })
await more.load()
assert.equal(more.canLoadMore(), true)
const done = new TopicFeedPage({ memoDb: makeMemoDb([], { hasMore: false }), room: 'bitcoin' })
await done.load()
assert.equal(done.canLoadMore(), false)
})
test('canLoadMore returns false when pagination is null or missing hasMore', async () => {
const pageNull = new TopicFeedPage({ memoDb: makeMemoDb([], null), room: 'bitcoin' })
await pageNull.load()
assert.equal(pageNull.canLoadMore(), false)
const pageEmpty = new TopicFeedPage({ memoDb: makeMemoDb([], { total: 0 }), room: 'bitcoin' })
await pageEmpty.load()
assert.equal(pageEmpty.canLoadMore(), false)
})
+2 -1
View File
@@ -42,7 +42,8 @@ if grep -q "Missing source file argument" \
else
bad "mutate4javascript"
fi
if psf-memo-client/node_modules/.bin/dry4javascript >/dev/null 2>&1; then
if grep -q "Usage: dry4javascript" \
<<< "$(psf-memo-client/node_modules/.bin/dry4javascript --help 2>&1)"; then
ok "dry4javascript"
else
bad "dry4javascript"