Review feed tabs: consume controller snapshot, kill mutation survivors

Route the posts view through FeedTabsPage.getState() instead of reaching
into the controller's internal fields, and consolidate the default-mode
unit tests. Add initial-state and unloaded-pagination tests that kill the
four constructor/canLoadMore mutation survivors (22 killed, 0 survived,
0 uncovered).

By architect.
This commit is contained in:
Chris Troutner
2026-09-20 10:36:45 -07:00
parent 4b8b4ed499
commit e4bda31256
4 changed files with 54 additions and 22 deletions
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-09-20T17:33:46.020711102Z","feature_name":"Feed Tabs","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/feed-tabs.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Feed Tabs - 1, Feed Tabs - 2, Feed Tabs - 3, Feed Tabs - 4, Feed Tabs - 5, Feed Tabs - 6, Feed Tabs - 7, Feed Tabs - 8
#
# The posts feed at /posts/recent is the single posts page. It shows a row of
@@ -43,16 +43,18 @@ function RecentPosts (props) {
const pageRef = useRef(null)
// Reflect a loaded controller page into React state, including the author
// profiles needed by the post cards.
// profiles needed by the post cards. The controller snapshot is the single
// source of feed state; the view never reaches into its internal fields.
const showPage = async (page) => {
const addrs = collectPostAddrs(page.posts)
const state = page.getState()
const addrs = collectPostAddrs(state.posts)
const profileMap = await loadThreadProfiles(addrs, page.memoDb)
setPosts(page.posts)
setPagination(page.pagination)
setMode(page.mode)
setEmptyBecauseNoFollows(page.emptyBecauseNoFollows)
setOffset(page.offset)
setPosts(state.posts)
setPagination(state.pagination)
setMode(state.mode)
setEmptyBecauseNoFollows(state.emptyBecauseNoFollows)
setOffset(state.offset)
setProfiles(profileMap)
}
@@ -155,3 +155,7 @@ FeedTabsPage.RECENT_MODE = RECENT_MODE
FeedTabsPage.FOLLOWING_MODE = FOLLOWING_MODE
module.exports = FeedTabsPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-20T17:31:02.596Z","module_hash":"df20b58c9b7744a421ffa2e1b457fd1416fb44e11e8e969647bebc96bde7c3bc","functions":[{"id":"func/FeedTabsPage.constructor","name":"FeedTabsPage.constructor","line":21,"end_line":34,"hash":"5383666103ae9a7a9bd4618280a48f5ffdbb8dd3b3f731ceb0d102f1f8487d6b"},{"id":"func/FeedTabsPage.getMyAddress","name":"FeedTabsPage.getMyAddress","line":36,"end_line":38,"hash":"3e5d4ac4df379300933a772020528b4ecf4ed83c7386a066f5c270df81adcddd"},{"id":"func/FeedTabsPage.open","name":"FeedTabsPage.open","line":42,"end_line":58,"hash":"23ed42e89c0fe89af20d936fd0175d9728a2a671379970ef862ad90941fdd374"},{"id":"func/FeedTabsPage.selectTab","name":"FeedTabsPage.selectTab","line":63,"end_line":74,"hash":"99ff87aedeb0019b22459e950e2a99df2648477e6ef3053921a487b636b95f86"},{"id":"func/FeedTabsPage._loadMode","name":"FeedTabsPage._loadMode","line":77,"end_line":84,"hash":"99d59c57f03b3ebb6cc8e1dd4aab8df019a34712bee72cc742d208b6b6a8b8c3"},{"id":"func/FeedTabsPage._pageFor","name":"FeedTabsPage._pageFor","line":86,"end_line":88,"hash":"2ecf46b5da42fb117b0fd4a2fcf7621a6609d99c8f2d7220a41a22c15000ede7"},{"id":"func/FeedTabsPage._isEmptyFollowing","name":"FeedTabsPage._isEmptyFollowing","line":92,"end_line":94,"hash":"baef393ae6c886f86edb26a15ddb582d16ee862df42b730ef94378c2c19df991"},{"id":"func/FeedTabsPage._normalizeTab","name":"FeedTabsPage._normalizeTab","line":96,"end_line":101,"hash":"3f9e921fd03e8a121d2c9d7db84ca96f8ab348098e5510d461b19e3d9d120f98"},{"id":"func/FeedTabsPage.isRecent","name":"FeedTabsPage.isRecent","line":103,"end_line":105,"hash":"dd7b7035d81fb710da80bef4db78e68620b9a6dbea652c1d2d7f9b2bbb1bf812"},{"id":"func/FeedTabsPage.isFollowing","name":"FeedTabsPage.isFollowing","line":107,"end_line":109,"hash":"2e05c1dad4d206852fb5d84784a2926b3beec11bcd3a1115798c79db70db6ec4"},{"id":"func/FeedTabsPage.canLoadMore","name":"FeedTabsPage.canLoadMore","line":111,"end_line":113,"hash":"634983bcc6bbe560daad8326db0dd4bf31d5cb9e45c40112565351dceaf8e5d5"},{"id":"func/FeedTabsPage.nextPage","name":"FeedTabsPage.nextPage","line":115,"end_line":124,"hash":"c4f4db842625b89422158164ed4ad1a67971e592ca3e8e7109790b3e09a1746e"},{"id":"func/FeedTabsPage.previousPage","name":"FeedTabsPage.previousPage","line":126,"end_line":136,"hash":"d0ac6bcd201e128a16b1404116b665c3edeb0849b8ae8de1014892d1410638da"},{"id":"func/FeedTabsPage.getPost","name":"FeedTabsPage.getPost","line":138,"end_line":140,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/FeedTabsPage.getState","name":"FeedTabsPage.getState","line":142,"end_line":150,"hash":"84595f8c1f3f00e0e410c96fe1084801d1469a606378a8b15d5a3b6ca03f1bc9"}]}
// mutate4javascript-manifest-end
@@ -57,32 +57,54 @@ async function openWithOnePost ({ following, feed, key }) {
return { page, memoDb, post }
}
// Assert that open selected exactly the feed implied by the follow state and
// loaded only that feed's single post.
function assertOnlySelectedFeedLoaded ({ page, memoDb, post, following }) {
const follows = following.length > 0
assert.equal(page.isFollowing(), follows)
assert.equal(page.isRecent(), !follows)
assert.deepEqual(page.posts, [post])
assert.equal(memoDb.calls.getFollowingFeed.length, follows ? 1 : 0)
assert.equal(memoDb.calls.getRecentPosts.length, follows ? 0 : 1)
}
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 { page, memoDb, post } = await openWithOnePost({ following: [ALICE], feed: 'followingFeed', key: 'a' })
test('constructor starts in a neutral, unloaded state', () => {
const page = new FeedTabsPage({ memoDb: makeMemoDb(), wallet: makeWallet() })
assert.equal(page.isFollowing(), true)
assert.equal(page.isRecent(), false)
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)
assert.equal(page.mode, null)
assert.equal(page.offset, 0)
assert.deepEqual(page.posts, [])
assert.equal(page.pagination, null)
assert.equal(page.hasFollows, false)
assert.equal(page.emptyBecauseNoFollows, false)
})
test('open selects Recent and loads recent posts when the viewer follows no one', async () => {
const { page, memoDb, post } = await openWithOnePost({ following: [], feed: 'recent', key: 'b' })
test('canLoadMore is false before any page has been loaded', () => {
const page = new FeedTabsPage({ memoDb: makeMemoDb(), wallet: makeWallet() })
assert.equal(page.isRecent(), true)
assert.equal(page.isFollowing(), false)
assert.deepEqual(page.posts, [post])
assert.equal(memoDb.calls.getRecentPosts.length, 1)
assert.equal(memoDb.calls.getFollowingFeed.length, 0)
assert.equal(page.canLoadMore(), false)
})
// The two default-mode cases differ only in the follow state and the feed
// that should be loaded, so they run as one table.
const DEFAULT_MODE_CASES = [
{ label: 'the viewer follows an account', following: [ALICE], feed: 'followingFeed', key: 'a' },
{ label: 'the viewer follows no one', following: [], feed: 'recent', key: 'b' }
]
for (const testCase of DEFAULT_MODE_CASES) {
test(`open selects the default feed and loads its posts when ${testCase.label}`, async () => {
const { page, memoDb, post } = await openWithOnePost(testCase)
assertOnlySelectedFeedLoaded({ page, memoDb, post, following: testCase.following })
})
}
test('open asks the memo db which accounts the viewer follows', async () => {
const memoDb = makeMemoDb({ following: [ALICE] })
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })