mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 17:22:01 -07:00
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:
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user