Implement New Post Page

Adds a New Post page for composing and broadcasting Memo posts:
- src/services/new-post.js: testable controller (input, character counter
  counting down from the memo limit, validation/length errors, navigation to
  the recent feed after a successful post).
- src/components/app-body/new-post: React view with a message textarea, post
  button, and remaining-character counter.
- Route /posts/new and a navigation menu link.
- Extend acceptance step handlers to cover both specs/post-memo.feature and
  specs/memo-new.feature wording; add unit tests for the controller.
- Drop redundant 'use strict' in compiled modules to fix the CI build warning.

By coder.
This commit is contained in:
Chris Troutner
2026-08-25 17:00:27 -07:00
parent 4fa92d16f6
commit 05f393c03f
7 changed files with 409 additions and 34 deletions
+87 -32
View File
@@ -1,18 +1,23 @@
/*
Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to the real project behavior in
src/services/memo-post.js, driving it through small injected adapters (a fake
wallet and a fake feed) so the acceptance run is deterministic and offline.
These handlers connect Gherkin step text to real project behavior
(src/services/memo-post.js and src/services/new-post.js), driving them
through small injected adapters (a fake wallet, a fake feed, and a fake
navigator) so the acceptance run is deterministic and offline.
Regex matching with placeholder-name capture is the default style: a single
handler pattern captures the placeholder name (e.g. <message>) and fetches
the example value from the scenario example store.
The handlers serve both specs/post-memo.feature and specs/memo-new.feature,
whose wording differs but which share the same underlying Memo post behavior.
*/
'use strict'
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
@@ -47,17 +52,27 @@ function createWorld () {
const wallet = makeWallet('')
const feed = makeFeed()
const memoPost = new MemoPost({ wallet, feed })
return {
const world = {
wallet,
feed,
memoPost,
message: null,
submitted: null
currentPath: null,
menuOpen: false
}
// The New Post Page controller wraps the memo post behavior. Its navigate
// adapter updates the world's current path so navigation can be asserted.
world.newPage = new NewPostPage({
memoPost,
navigate: (path) => { world.currentPath = path },
menuLinks: []
})
return world
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world).
// run receives (match, exampleStore, world, step).
const handlers = [
{
name: 'wallet authenticated for address',
@@ -68,7 +83,7 @@ const handlers = [
},
{
name: 'wallet has spendable output',
pattern: /^the wallet has a spendable output to pay the transaction fee$/,
pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/,
run (m, example, world) {
world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }]
}
@@ -76,34 +91,62 @@ const handlers = [
{
name: 'viewing recent posts feed',
pattern: /^I am viewing the recent posts feed$/,
run () {}
run (m, example, world) {
world.currentPath = NewPostPage.RECENT_FEED_PATH
}
},
{
name: 'compose memo text',
pattern: /^I compose a memo with the text "<([A-Za-z0-9_]+)>"$/,
name: 'navigate to path',
pattern: /^I navigate to the path (.+)$/,
run (m, example, world, step) {
const target = m[1].trim()
if (step.keyword === 'Then') {
if (world.currentPath !== target) {
throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`)
}
} else {
world.currentPath = target
}
}
},
{
name: 'open navigation menu',
pattern: /^I open the navigation menu$/,
run (m, example, world) {
world.menuOpen = true
}
},
{
name: 'menu shows link to path',
pattern: /^the menu shows a link to the path (.+)$/,
run (m, example, world) {
const target = m[1].trim()
if (!world.newPage.hasMenuLink(target)) {
throw new Error(`Navigation menu does not link to ${target}.`)
}
}
},
{
name: 'compose/type memo text',
pattern: /^I (?:compose|type) a memo with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.message = example[param]
world.newPage.setInput(example[param])
}
},
{
name: 'submit memo',
pattern: /^I submit the memo$/,
name: 'submit/click post',
pattern: /^I (?:submit the memo|click the post button)$/,
async run (m, example, world) {
world.submitted = { error: null, txid: null }
try {
world.submitted.txid = await world.memoPost.post(world.message)
} catch (err) {
world.submitted.error = err
}
await world.newPage.submit()
}
},
{
name: 'broadcasts OP_RETURN with Memo post prefix',
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo post prefix$/,
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo post prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
@@ -113,7 +156,7 @@ const handlers = [
if (last.prefix !== MEMO_POST_PREFIX) {
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.message) {
if (last.msg !== world.newPage.input) {
throw new Error('Broadcast message text did not match the composed memo.')
}
}
@@ -134,22 +177,34 @@ const handlers = [
}
},
{
name: 'app shows validation/length error',
pattern: /^the app shows a (validation|length) error$/,
name: 'page shows validation/length error',
pattern: /^the (?:app|new post page) shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
if (!world.submitted || !world.submitted.error) {
throw new Error(`Expected a ${kind} error but the submit succeeded.`)
}
if (world.submitted.error.code !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.submitted.error.code}.`)
if (world.newPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`)
}
}
},
{
name: 'wallet does not broadcast any transaction',
pattern: /^the wallet does not broadcast any transaction$/,
name: 'remaining character count',
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.newPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining characters, got ${actual}.`)
}
}
},
{
name: 'app does not broadcast any transaction',
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
run (m, example, world) {
if (world.wallet.broadcasts.length !== 0) {
throw new Error('A transaction was broadcast when none was expected.')
@@ -163,7 +218,7 @@ async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
if (match) {
await handler.run(match, example, world)
await handler.run(match, example, world, step)
return
}
}