diff --git a/psf-memo-client/specs/feed-tabs.feature b/psf-memo-client/specs/feed-tabs.feature index 47427f9..ced5634 100644 --- a/psf-memo-client/specs/feed-tabs.feature +++ b/psf-memo-client/specs/feed-tabs.feature @@ -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 diff --git a/psf-memo-client/src/components/app-body/posts/index.js b/psf-memo-client/src/components/app-body/posts/index.js index 90d442d..df40890 100644 --- a/psf-memo-client/src/components/app-body/posts/index.js +++ b/psf-memo-client/src/components/app-body/posts/index.js @@ -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) } diff --git a/psf-memo-client/src/services/feed-tabs-page.js b/psf-memo-client/src/services/feed-tabs-page.js index 840f3d7..0b21da0 100644 --- a/psf-memo-client/src/services/feed-tabs-page.js +++ b/psf-memo-client/src/services/feed-tabs-page.js @@ -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 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 9bbb5f4..92c0725 100644 --- a/psf-memo-client/test/unit/feed-tabs-page.test.js +++ b/psf-memo-client/test/unit/feed-tabs-page.test.js @@ -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() })