Refactor feed tabs: simplify mode loading, add feed-tabs properties

Reduce FeedTabsPage._loadMode complexity below the CRAP threshold by
extracting the mode-to-page lookup and the empty-Following predicate, and
drop the unused getActiveTabLabel helper. Add property tests for default
mode selection, delegation, tab-switch reset, paging, and empty-state
invariants, and share the default-mode unit-test setup. FeedTabsPage
functions are at 100% coverage with CRAP <= 5.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-20 10:16:14 -07:00
parent d1372dac11
commit 4b8b4ed499
3 changed files with 326 additions and 31 deletions
+13 -13
View File
@@ -75,18 +75,22 @@ class FeedTabsPage {
// Load the current tab at the current offset through its page controller.
async _loadMode () {
if (this.mode === FOLLOWING_MODE) {
const data = await this.followingPage.load({ limit: this.pageSize, offset: this.offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.emptyBecauseNoFollows = this.posts.length === 0 && !this.hasFollows
return
}
const page = this._pageFor(this.mode)
const data = await page.load({ limit: this.pageSize, offset: this.offset })
const data = await this.recentPage.load({ limit: this.pageSize, offset: this.offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.emptyBecauseNoFollows = false
this.emptyBecauseNoFollows = this._isEmptyFollowing()
}
_pageFor (mode) {
return mode === FOLLOWING_MODE ? this.followingPage : this.recentPage
}
// The not-following-anyone message belongs only to an empty Following tab
// when the viewer follows no one; the Recent tab never shows it.
_isEmptyFollowing () {
return this.mode === FOLLOWING_MODE && this.posts.length === 0 && !this.hasFollows
}
_normalizeTab (tab) {
@@ -104,10 +108,6 @@ class FeedTabsPage {
return this.mode === FOLLOWING_MODE
}
getActiveTabLabel () {
return this.mode === FOLLOWING_MODE ? 'Following' : 'Recent'
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
@@ -0,0 +1,286 @@
/*
Property tests for the merged FeedTabsPage controller.
The unit tests probe a few fixed tab/pagination shapes. These properties
cover the mode-selection, delegation, tab-switch, paging, and empty-state
invariants over broad random inputs so they hold everywhere:
- default mode: open selects Following exactly when the viewer has an
address and follows at least one account, otherwise Recent, and loads
exactly the selected feed once.
- forwarding: the requested limit and offset reach the selected feed.
- tab switch: selecting a different tab resets the offset to the first
page; selecting the active tab reloads nothing.
- paging: nextPage advances by one page only when more pages exist;
previousPage never moves before the first page.
- empty state: emptyBecauseNoFollows is true exactly for an empty
Following tab when the viewer follows no one.
- state/lookup: getState mirrors the controller and getPost finds a
loaded post by txid and returns null otherwise.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const FeedTabsPage = require('../../src/services/feed-tabs-page')
const rng = seededRandom(20260920)
const MY = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const ADDRESSES = [
'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy',
'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r',
'bitcoincash:qzlg6uvceehgzgtz6phmvy8gtdqyt6vf3uqpvqp4d8'
]
const HEX = '0123456789abcdef'
function txidGen () {
let out = ''
for (let i = 0; i < 64; i++) {
out += HEX[Math.floor(rng() * HEX.length)]
}
return out
}
function feedGen () {
const n = intGen(rng, 0, 4)()
const posts = []
for (let i = 0; i < n; i++) {
posts.push({ txid: txidGen(), text: 'post ' + i })
}
return {
posts,
pagination: { total: n, limit: 50, offset: 0, hasMore: rng() < 0.5 }
}
}
function scenarioGen () {
return () => {
const hasWallet = rng() < 0.8
const followCount = intGen(rng, 0, ADDRESSES.length)()
const following = ADDRESSES.slice(0, followCount)
return {
wallet: hasWallet ? { walletInfo: { cashAddress: MY } } : null,
following,
recent: feedGen(),
followingFeed: feedGen(),
limit: intGen(rng, 1, 100)(),
offset: intGen(rng, 0, 200)()
}
}
}
function makeHarness (scenario) {
const calls = { getFollowing: [], getRecentPosts: [], getFollowingFeed: [] }
const memoDb = {
async getFollowing (addr) {
calls.getFollowing.push(addr)
return scenario.following
},
async getRecentPosts (opts) {
calls.getRecentPosts.push(opts)
return scenario.recent
},
async getFollowingFeed (addr, opts) {
calls.getFollowingFeed.push({ addr, opts })
return scenario.followingFeed
}
}
return { page: new FeedTabsPage({ memoDb, wallet: scenario.wallet }), calls }
}
function expectsFollowing (scenario) {
return Boolean(scenario.wallet) && scenario.following.length > 0
}
test('open selects Following exactly when the viewer follows an account', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page, calls } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
const following = expectsFollowing(scenario)
return page.isFollowing() === following &&
page.isRecent() === !following &&
calls.getFollowing.length === (scenario.wallet ? 1 : 0) &&
calls.getFollowingFeed.length === (following ? 1 : 0) &&
calls.getRecentPosts.length === (following ? 0 : 1)
},
{ label: 'default mode selection' }
)
})
test('open forwards the requested limit and offset to the selected feed', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page, calls } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
if (expectsFollowing(scenario)) {
const call = calls.getFollowingFeed[0]
return call.addr === MY &&
call.opts.limit === scenario.limit &&
call.opts.offset === scenario.offset
}
const call = calls.getRecentPosts[0]
return call.limit === scenario.limit &&
call.offset === scenario.offset &&
call.viewer === (scenario.wallet ? MY : undefined)
},
{ label: 'open forwards limit and offset' }
)
})
test('the loaded posts and pagination come from the selected feed', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
const expected = expectsFollowing(scenario) ? scenario.followingFeed : scenario.recent
return page.posts === expected.posts && page.pagination === expected.pagination
},
{ label: 'selected feed contents' }
)
})
test('switching to the other tab resets the offset to the first page', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
if (page.isFollowing()) {
await page.selectTab('Recent')
return page.offset === 0 && page.isRecent()
}
if (!scenario.wallet) {
// The Following tab needs an authenticated wallet to load, so it is
// not selectable in this scenario.
return page.offset === scenario.offset && page.isRecent()
}
await page.selectTab('Following')
return page.offset === 0 && page.isFollowing()
},
{ label: 'tab switch resets offset' }
)
})
test('selecting the active tab reloads nothing', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page, calls } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
page.offset = scenario.offset + 10
const label = page.isFollowing() ? 'following' : 'recent'
const loadsBefore = calls.getRecentPosts.length + calls.getFollowingFeed.length
await page.selectTab(label)
return page.offset === scenario.offset + 10 &&
calls.getRecentPosts.length + calls.getFollowingFeed.length === loadsBefore
},
{ label: 'active tab reload' }
)
})
test('nextPage advances by one page exactly when more pages exist', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page, calls } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
const before = page.offset
const hadMore = page.canLoadMore()
const loadsBefore = calls.getRecentPosts.length + calls.getFollowingFeed.length
await page.nextPage()
const loadsAfter = calls.getRecentPosts.length + calls.getFollowingFeed.length
if (hadMore) {
return page.offset === before + page.pageSize && loadsAfter === loadsBefore + 1
}
return page.offset === before && loadsAfter === loadsBefore
},
{ label: 'nextPage invariant' }
)
})
test('previousPage never moves before the first page', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
await page.previousPage()
return page.offset === Math.max(0, scenario.offset - scenario.limit)
},
{ label: 'previousPage clamp' }
)
})
test('emptyBecauseNoFollows is true exactly for an empty Following tab with no followees', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
if (scenario.wallet && scenario.following.length === 0) {
await page.selectTab('Following')
}
const expected = page.mode === FeedTabsPage.FOLLOWING_MODE &&
page.posts.length === 0 &&
scenario.following.length === 0
return page.emptyBecauseNoFollows === expected
},
{ label: 'emptyBecauseNoFollows invariant' }
)
})
test('getState mirrors the controller state', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
const state = page.getState()
return state.mode === page.mode &&
state.offset === page.offset &&
state.posts === page.posts &&
state.pagination === page.pagination &&
state.emptyBecauseNoFollows === page.emptyBecauseNoFollows
},
{ label: 'getState mirror' }
)
})
test('getPost returns a loaded post by txid and null otherwise', async () => {
await forAll(
scenarioGen(),
async (scenario) => {
const { page } = makeHarness(scenario)
await page.open({ limit: scenario.limit, offset: scenario.offset })
if (page.posts.length === 0) {
return page.getPost('not-a-txid') === null
}
return page.getPost(page.posts[0].txid) === page.posts[0] &&
page.getPost('not-a-txid') === null
},
{ label: 'getPost lookup' }
)
})
@@ -43,42 +43,42 @@ function makeMemoDb ({
}
}
// Build a page whose selected feed holds exactly one post, open it, and
// return the page, its memo-db spy, and the post. The two default-mode tests
// differ only in which feed holds the post.
async function openWithOnePost ({ following, feed, key }) {
const post = { txid: key.repeat(64), text: key }
const memoDb = makeMemoDb({
following,
[feed]: { posts: [post], pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
})
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
await page.open()
return { page, memoDb, post }
}
test('exposes the Recent and Following tabs in order', () => {
const page = new FeedTabsPage({ memoDb: makeMemoDb(), wallet: makeWallet() })
assert.deepEqual(page.tabs, ['Recent', 'Following'])
})
test('open selects Following and loads followed posts when the viewer follows an account', async () => {
const followed = [{ txid: 'a'.repeat(64), text: 'followed' }]
const memoDb = makeMemoDb({
following: [ALICE],
followingFeed: { posts: followed, pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
})
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
await page.open()
const { page, memoDb, post } = await openWithOnePost({ following: [ALICE], feed: 'followingFeed', key: 'a' })
assert.equal(page.isFollowing(), true)
assert.equal(page.isRecent(), false)
assert.deepEqual(page.posts, followed)
assert.deepEqual(page.posts, [post])
assert.equal(memoDb.calls.getFollowing.length, 1)
assert.equal(memoDb.calls.getRecentPosts.length, 0)
assert.equal(memoDb.calls.getFollowingFeed.length, 1)
})
test('open selects Recent and loads recent posts when the viewer follows no one', async () => {
const recent = [{ txid: 'b'.repeat(64), text: 'recent' }]
const memoDb = makeMemoDb({
following: [],
recent: { posts: recent, pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
})
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
await page.open()
const { page, memoDb, post } = await openWithOnePost({ following: [], feed: 'recent', key: 'b' })
assert.equal(page.isRecent(), true)
assert.equal(page.isFollowing(), false)
assert.deepEqual(page.posts, recent)
assert.deepEqual(page.posts, [post])
assert.equal(memoDb.calls.getRecentPosts.length, 1)
assert.equal(memoDb.calls.getFollowingFeed.length, 0)
})
@@ -163,6 +163,15 @@ test('selecting the already active tab does not reload', async () => {
assert.equal(memoDb.calls.getRecentPosts.length, 1)
})
test('selectTab rejects an unknown tab name', async () => {
const memoDb = makeMemoDb({ following: [] })
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
await page.open()
await assert.rejects(() => page.selectTab('Topics'), /Unknown feed tab: Topics/)
})
test('Following tab with no followees shows the not-following-anyone message', async () => {
const memoDb = makeMemoDb({ following: [] })
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })