mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Refactor paginated pages onto shared base, add property coverage
Extract PaginatedPage base for the recent feed and recent profiles page controllers to remove the duplicated load/canLoadMore pattern introduced with the 50-item page size. Refresh mutation manifests via the tool. Add property tests for the base pagination invariants (canLoadMore mirrors hasMore, load forwards limit/offset, item lookup). By refactorer.
This commit is contained in:
@@ -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:03:38.880Z","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
|
||||
@@ -10,34 +10,22 @@
|
||||
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 = 50, 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) {
|
||||
return this.posts.find((post) => post.txid === txid) || null
|
||||
}
|
||||
|
||||
canLoadMore () {
|
||||
return this.pagination?.hasMore ?? false
|
||||
}
|
||||
}
|
||||
|
||||
RecentFeedPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||
@@ -45,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:03:39.630Z","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
|
||||
|
||||
@@ -6,29 +6,17 @@
|
||||
view can render them.
|
||||
*/
|
||||
|
||||
const PaginatedPage = require('./paginated-page')
|
||||
|
||||
const RECENT_PROFILES_PATH = '/profile/recent'
|
||||
|
||||
class RecentProfilesPage {
|
||||
class RecentProfilesPage extends PaginatedPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.profiles = []
|
||||
this.pagination = null
|
||||
}
|
||||
|
||||
async load ({ limit = 50, offset = 0 } = {}) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Recent profiles page requires a memo db client.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getRecentProfiles({ limit, offset })
|
||||
this.profiles = data.profiles || []
|
||||
this.pagination = data.pagination || null
|
||||
|
||||
return { profiles: this.profiles, pagination: this.pagination }
|
||||
}
|
||||
|
||||
canLoadMore () {
|
||||
return this.pagination?.hasMore ?? false
|
||||
super(deps, {
|
||||
listField: 'profiles',
|
||||
loadMethod: 'getRecentProfiles',
|
||||
errorMessage: 'Recent profiles page requires a memo db client.'
|
||||
})
|
||||
}
|
||||
|
||||
getProfile (addr) {
|
||||
@@ -39,3 +27,7 @@ class RecentProfilesPage {
|
||||
RecentProfilesPage.RECENT_PROFILES_PATH = RECENT_PROFILES_PATH
|
||||
|
||||
module.exports = RecentProfilesPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-04T17:03:40.330Z","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
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user