From 4b8b4ed499990d2cad54ce053a55c69e73474b44 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 20 Sep 2026 10:16:14 -0700 Subject: [PATCH] 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. --- .../src/services/feed-tabs-page.js | 26 +- .../property/feed-tabs-page.property.test.js | 286 ++++++++++++++++++ .../test/unit/feed-tabs-page.test.js | 45 +-- 3 files changed, 326 insertions(+), 31 deletions(-) create mode 100644 psf-memo-client/test/property/feed-tabs-page.property.test.js diff --git a/psf-memo-client/src/services/feed-tabs-page.js b/psf-memo-client/src/services/feed-tabs-page.js index 9f100e5..840f3d7 100644 --- a/psf-memo-client/src/services/feed-tabs-page.js +++ b/psf-memo-client/src/services/feed-tabs-page.js @@ -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 } diff --git a/psf-memo-client/test/property/feed-tabs-page.property.test.js b/psf-memo-client/test/property/feed-tabs-page.property.test.js new file mode 100644 index 0000000..e53ad21 --- /dev/null +++ b/psf-memo-client/test/property/feed-tabs-page.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-client/test/unit/feed-tabs-page.test.js b/psf-memo-client/test/unit/feed-tabs-page.test.js index 0c60906..9bbb5f4 100644 --- a/psf-memo-client/test/unit/feed-tabs-page.test.js +++ b/psf-memo-client/test/unit/feed-tabs-page.test.js @@ -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() })