diff --git a/docs/reviews/page-size-50-summary.md b/docs/reviews/page-size-50-summary.md new file mode 100644 index 0000000..4177d67 --- /dev/null +++ b/docs/reviews/page-size-50-summary.md @@ -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 --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). diff --git a/docs/reviews/youtube-embed-summary.md b/docs/reviews/youtube-embed-summary.md index 04c2f94..09da7ea 100644 --- a/docs/reviews/youtube-embed-summary.md +++ b/docs/reviews/youtube-embed-summary.md @@ -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. diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index b0dc458..6637243 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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) } }, { diff --git a/psf-memo-client/specs/page-size.feature b/psf-memo-client/specs/page-size.feature index 2765580..eec010b 100644 --- a/psf-memo-client/specs/page-size.feature +++ b/psf-memo-client/specs/page-size.feature @@ -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. diff --git a/psf-memo-client/src/components/app-body/following-feed/index.js b/psf-memo-client/src/components/app-body/following-feed/index.js index dd9bae3..83d2b6b 100644 --- a/psf-memo-client/src/components/app-body/following-feed/index.js +++ b/psf-memo-client/src/components/app-body/following-feed/index.js @@ -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 diff --git a/psf-memo-client/src/components/app-body/notifications/index.js b/psf-memo-client/src/components/app-body/notifications/index.js index f233282..fae4b8d 100644 --- a/psf-memo-client/src/components/app-body/notifications/index.js +++ b/psf-memo-client/src/components/app-body/notifications/index.js @@ -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') { diff --git a/psf-memo-client/src/components/app-body/posts/index.js b/psf-memo-client/src/components/app-body/posts/index.js index eee1fe8..0123287 100644 --- a/psf-memo-client/src/components/app-body/posts/index.js +++ b/psf-memo-client/src/components/app-body/posts/index.js @@ -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 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 cab899d..18e7d39 100644 --- a/psf-memo-client/src/components/app-body/profile/index.js +++ b/psf-memo-client/src/components/app-body/profile/index.js @@ -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 ( {error &&

{error}

} @@ -222,7 +236,7 @@ function Profile (props) {

Posts

{pagination && ( - {pagination.total} post{pagination.total === 1 ? '' : 's'} + {pagination.offset + 1}–{pagination.offset + posts.length} of {pagination.total} posts )} @@ -249,6 +263,26 @@ function Profile (props) { ))} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} )} diff --git a/psf-memo-client/src/components/app-body/recent-profiles/index.js b/psf-memo-client/src/components/app-body/recent-profiles/index.js index 210dcde..de7c5c2 100644 --- a/psf-memo-client/src/components/app-body/recent-profiles/index.js +++ b/psf-memo-client/src/components/app-body/recent-profiles/index.js @@ -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 ( @@ -102,6 +121,26 @@ function RecentProfiles () { )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )}
diff --git a/psf-memo-client/src/components/app-body/search/index.js b/psf-memo-client/src/components/app-body/search/index.js index c6a96b3..3af6089 100644 --- a/psf-memo-client/src/components/app-body/search/index.js +++ b/psf-memo-client/src/components/app-body/search/index.js @@ -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 ( @@ -123,6 +161,26 @@ function Search (props) { )} {!loading && } + + {!loading && searched && (pagination || offset > 0) && ( +
+ + + +
+ )}
diff --git a/psf-memo-client/src/components/app-body/topic-feed/index.js b/psf-memo-client/src/components/app-body/topic-feed/index.js index 1086f9e..e24461c 100644 --- a/psf-memo-client/src/components/app-body/topic-feed/index.js +++ b/psf-memo-client/src/components/app-body/topic-feed/index.js @@ -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 diff --git a/psf-memo-client/src/services/following-feed-page.js b/psf-memo-client/src/services/following-feed-page.js index 9d2a88c..81263ed 100644 --- a/psf-memo-client/src/services/following-feed-page.js +++ b/psf-memo-client/src/services/following-feed-page.js @@ -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 diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 5775ba4..4f77965 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -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 } diff --git a/psf-memo-client/src/services/notifications-page.js b/psf-memo-client/src/services/notifications-page.js index 01de74b..fd68282 100644 --- a/psf-memo-client/src/services/notifications-page.js +++ b/psf-memo-client/src/services/notifications-page.js @@ -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 diff --git a/psf-memo-client/src/services/paginated-page.js b/psf-memo-client/src/services/paginated-page.js new file mode 100644 index 0000000..e61e2cf --- /dev/null +++ b/psf-memo-client/src/services/paginated-page.js @@ -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 diff --git a/psf-memo-client/src/services/profile-page.js b/psf-memo-client/src/services/profile-page.js index c46b909..f6c9a18 100644 --- a/psf-memo-client/src/services/profile-page.js +++ b/psf-memo-client/src/services/profile-page.js @@ -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 diff --git a/psf-memo-client/src/services/recent-feed-page.js b/psf-memo-client/src/services/recent-feed-page.js index b4caea1..341a628 100644 --- a/psf-memo-client/src/services/recent-feed-page.js +++ b/psf-memo-client/src/services/recent-feed-page.js @@ -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 diff --git a/psf-memo-client/src/services/recent-profiles-page.js b/psf-memo-client/src/services/recent-profiles-page.js new file mode 100644 index 0000000..1efb83b --- /dev/null +++ b/psf-memo-client/src/services/recent-profiles-page.js @@ -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 diff --git a/psf-memo-client/src/services/search-page.js b/psf-memo-client/src/services/search-page.js index e3e169d..a0b174c 100644 --- a/psf-memo-client/src/services/search-page.js +++ b/psf-memo-client/src/services/search-page.js @@ -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 diff --git a/psf-memo-client/src/services/topic-feed-page.js b/psf-memo-client/src/services/topic-feed-page.js index 4b16ca4..892cbfd 100644 --- a/psf-memo-client/src/services/topic-feed-page.js +++ b/psf-memo-client/src/services/topic-feed-page.js @@ -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 diff --git a/psf-memo-client/test/property/paginated-page.property.test.js b/psf-memo-client/test/property/paginated-page.property.test.js new file mode 100644 index 0000000..57f02e2 --- /dev/null +++ b/psf-memo-client/test/property/paginated-page.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-client/test/property/youtube-embed.property.test.js b/psf-memo-client/test/property/youtube-embed.property.test.js new file mode 100644 index 0000000..30a33a9 --- /dev/null +++ b/psf-memo-client/test/property/youtube-embed.property.test.js @@ -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 } + ) +}) diff --git a/psf-memo-client/test/unit/following-feed-page.test.js b/psf-memo-client/test/unit/following-feed-page.test.js index 02d1a3f..f2307ed 100644 --- a/psf-memo-client/test/unit/following-feed-page.test.js +++ b/psf-memo-client/test/unit/following-feed-page.test.js @@ -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 () => { diff --git a/psf-memo-client/test/unit/notifications-page.test.js b/psf-memo-client/test/unit/notifications-page.test.js index defb825..b52029c 100644 --- a/psf-memo-client/test/unit/notifications-page.test.js +++ b/psf-memo-client/test/unit/notifications-page.test.js @@ -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({ diff --git a/psf-memo-client/test/unit/paginated-page.test.js b/psf-memo-client/test/unit/paginated-page.test.js new file mode 100644 index 0000000..4de0cef --- /dev/null +++ b/psf-memo-client/test/unit/paginated-page.test.js @@ -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) +}) diff --git a/psf-memo-client/test/unit/profile-page.test.js b/psf-memo-client/test/unit/profile-page.test.js index a27f3f0..958dc75 100644 --- a/psf-memo-client/test/unit/profile-page.test.js +++ b/psf-memo-client/test/unit/profile-page.test.js @@ -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) +}) diff --git a/psf-memo-client/test/unit/recent-feed-page.test.js b/psf-memo-client/test/unit/recent-feed-page.test.js index e82b8d2..c710e38 100644 --- a/psf-memo-client/test/unit/recent-feed-page.test.js +++ b/psf-memo-client/test/unit/recent-feed-page.test.js @@ -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 }]) }) diff --git a/psf-memo-client/test/unit/recent-profiles-page.test.js b/psf-memo-client/test/unit/recent-profiles-page.test.js new file mode 100644 index 0000000..b5c621f --- /dev/null +++ b/psf-memo-client/test/unit/recent-profiles-page.test.js @@ -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') +}) diff --git a/psf-memo-client/test/unit/search-page.test.js b/psf-memo-client/test/unit/search-page.test.js index 4791d63..506963f 100644 --- a/psf-memo-client/test/unit/search-page.test.js +++ b/psf-memo-client/test/unit/search-page.test.js @@ -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) +}) diff --git a/psf-memo-client/test/unit/topic-feed-page.test.js b/psf-memo-client/test/unit/topic-feed-page.test.js index 92a6c18..5f4557e 100644 --- a/psf-memo-client/test/unit/topic-feed-page.test.js +++ b/psf-memo-client/test/unit/topic-feed-page.test.js @@ -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) +}) diff --git a/swarmforge/scripts/architect-startup.sh b/swarmforge/scripts/architect-startup.sh index e36eab2..fe5498d 100755 --- a/swarmforge/scripts/architect-startup.sh +++ b/swarmforge/scripts/architect-startup.sh @@ -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"