Implement Search feature (P6.4)

- Add psf-memo-db /search endpoint with SearchQuery adapter, SearchAll use case,
  and REST controller/router.
- Add psf-memo-client SearchPage service, Search component, route, and nav link.
- Wire search support into the acceptance handler fake MemoDb.
- Add unit tests for the new adapter, use case, controller, and page service.

By coder.
This commit is contained in:
Chris Troutner
2026-08-29 07:18:39 -07:00
parent d11365c057
commit 8508fdaff0
17 changed files with 1053 additions and 1 deletions
+117 -1
View File
@@ -39,6 +39,7 @@ const ProfilePage = require('../../src/services/profile-page')
const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -178,9 +179,12 @@ function makeThread () {
}
// A fake psf-memo-db API backing the read-only feed, profile, thread,
// and topic pages used to verify read-side behavior.
// topic, and search pages used to verify read-side behavior.
function makeMemoDb () {
const posts = []
const profiles = []
const searchPosts = []
const searchProfiles = []
const threads = {}
const followState = {}
const muteState = {}
@@ -191,6 +195,9 @@ function makeMemoDb () {
return {
posts,
profiles,
searchPosts,
searchProfiles,
threads,
followState,
topics,
@@ -199,6 +206,12 @@ function makeMemoDb () {
addPost (post) {
posts.push(post)
},
addSearchPost (post) {
searchPosts.push(post)
},
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicPosts[room] = []
@@ -240,6 +253,25 @@ function makeMemoDb () {
}
return addrs
},
async search (q) {
const normalized = String(q).trim().toLowerCase()
if (normalized.length === 0) {
return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } }
}
const matchedPosts = searchPosts.filter((p) =>
typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)
)
const matchedProfiles = searchProfiles.filter((p) =>
(typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) ||
(typeof p.text === 'string' && p.text.toLowerCase().includes(normalized))
)
const total = matchedPosts.length + matchedProfiles.length
return {
posts: matchedPosts,
profiles: matchedProfiles,
pagination: { total, hasMore: false }
}
},
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
const page = posts.slice(offset, offset + limit)
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
@@ -316,6 +348,10 @@ function createWorld () {
memoDb,
navigate: (path) => { world.currentPath = path }
})
world.searchPage = new SearchPage({
memoDb,
navigate: (path) => { world.currentPath = path }
})
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
@@ -2088,6 +2124,86 @@ const handlers = [
throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`)
}
}
},
{
name: 'API has search post',
pattern: /^the psf-memo-db API has a post with the text "(.+)"$/,
run (m, example, world) {
const text = resolveParam(m[1], example)
const txid = require('crypto').createHash('sha256').update(text).digest('hex')
world.memoDb.addSearchPost({
txid,
addr: `addr-${txid.slice(0, 8)}`,
text,
blockHeight: 100
})
}
},
{
name: 'API has search profile',
pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/,
run (m, example, world) {
const name = resolveParam(m[1], example)
const text = resolveParam(m[2], example)
const addr = `addr-${require('crypto').createHash('sha256').update(name).digest('hex').slice(0, 8)}`
world.memoDb.addSearchProfile({ addr, name, text, blockHeight: 100 })
}
},
{
name: 'open search page',
pattern: /^I open the Search page$/,
async run (m, example, world) {
world.currentPath = SearchPage.SEARCH_PATH
}
},
{
name: 'submit search',
pattern: /^I submit a search for (.+)$/,
async run (m, example, world) {
const query = resolveParam(m[1], example)
world.searchPage.setQuery(query)
await world.searchPage.submit()
}
},
{
name: 'search results include post text',
pattern: /^the search results include a post with the text (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.posts.find((p) => p.text === expected)
if (!found) {
throw new Error(`Search results do not include a post with text "${expected}".`)
}
}
},
{
name: 'search results include profile name',
pattern: /^the search results include a profile named (.+)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example)
const found = world.searchPage.profiles.find((p) => p.name === expected)
if (!found) {
throw new Error(`Search results do not include a profile named "${expected}".`)
}
}
},
{
name: 'search results include no posts',
pattern: /^the search results include no posts$/,
run (m, example, world) {
if (world.searchPage.posts.length !== 0) {
throw new Error(`Expected no posts in search results, got ${world.searchPage.posts.length}.`)
}
}
},
{
name: 'search results include no profiles',
pattern: /^the search results include no profiles$/,
run (m, example, world) {
if (world.searchPage.profiles.length !== 0) {
throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`)
}
}
}
]