mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge completed Post a Memo and New Post Page work
By specifier.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
docs/
|
docs/
|
||||||
|
tmp/
|
||||||
|
|
||||||
.gitsigners
|
.gitsigners
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
Normal acceptance runner for psf-memo-client.
|
||||||
|
|
||||||
|
Orchestrates the acceptance pipeline:
|
||||||
|
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
|
||||||
|
generator -> generated test entry points -> node test runner
|
||||||
|
|
||||||
|
It procures the latest Babashka APS tools from the Acceptance-Pipeline-
|
||||||
|
Specification repository on first use, then parses, generates, and runs every
|
||||||
|
Gherkin feature under specs/.
|
||||||
|
|
||||||
|
Exit code 0 when all acceptance tests pass; non-zero otherwise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const specsDir = path.join(root, 'specs')
|
||||||
|
const buildDir = path.join(root, 'build', 'acceptance')
|
||||||
|
const irDir = path.join(buildDir, 'ir')
|
||||||
|
const genDir = path.join(buildDir, 'generated')
|
||||||
|
const apsDir = path.join(root, 'tmp', 'aps-spec')
|
||||||
|
|
||||||
|
function sh (cmd, args, opts = {}) {
|
||||||
|
return execFileSync(cmd, args, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
...opts
|
||||||
|
}).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procure the latest APS tools if not already present in the worktree.
|
||||||
|
function ensureAps () {
|
||||||
|
if (fs.existsSync(apsDir)) return
|
||||||
|
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
|
||||||
|
sh('git', ['clone', '--depth', '1',
|
||||||
|
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
|
||||||
|
}
|
||||||
|
|
||||||
|
function main () {
|
||||||
|
ensureAps()
|
||||||
|
|
||||||
|
const features = fs
|
||||||
|
.readdirSync(specsDir)
|
||||||
|
.filter((f) => f.endsWith('.feature'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
if (features.length === 0) {
|
||||||
|
console.log('No feature files found under specs/.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(irDir, { recursive: true })
|
||||||
|
fs.mkdirSync(genDir, { recursive: true })
|
||||||
|
|
||||||
|
for (const featureFile of features) {
|
||||||
|
const base = featureFile.replace(/\.feature$/i, '')
|
||||||
|
const featurePath = path.join(specsDir, featureFile)
|
||||||
|
const irPath = path.join(irDir, `${base}.json`)
|
||||||
|
|
||||||
|
// 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser.
|
||||||
|
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
|
||||||
|
|
||||||
|
// 2) Generate executable acceptance entry points from the IR.
|
||||||
|
sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Run every generated acceptance test.
|
||||||
|
const tests = fs
|
||||||
|
.readdirSync(genDir)
|
||||||
|
.filter((f) => f.endsWith('.acceptance.test.js'))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
let failures = 0
|
||||||
|
for (const testFile of tests) {
|
||||||
|
try {
|
||||||
|
const out = sh('node', [path.join(genDir, testFile)])
|
||||||
|
process.stdout.write(out)
|
||||||
|
console.log(`ACCEPTANCE PASS: ${testFile}`)
|
||||||
|
} catch (err) {
|
||||||
|
failures++
|
||||||
|
process.stdout.write(err.stdout || '')
|
||||||
|
process.stderr.write(err.stderr || '')
|
||||||
|
console.error(`ACCEPTANCE FAIL: ${testFile}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures > 0) {
|
||||||
|
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
|
||||||
|
process.exit(1)
|
||||||
|
} else {
|
||||||
|
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
Project-specific acceptance entrypoint generator.
|
||||||
|
|
||||||
|
Reads parser JSON IR and writes executable generated test entry points plus
|
||||||
|
per-feature metadata. Generated tests delegate all step behavior to the
|
||||||
|
acceptance runtime and project step handlers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
node acceptance/lib/generate.js <json-ir> <generated-test-output-dir>
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 generation succeeded
|
||||||
|
1 generation error
|
||||||
|
2 wrong command usage
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
|
||||||
|
// Convert a feature path to a strict lowercase-and-hyphen metadata filename.
|
||||||
|
function metadataName (featureName) {
|
||||||
|
const slug = featureName
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
return `${slug || 'feature'}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute a stable relative require path from a generated file's directory to
|
||||||
|
// a target module, with a './' or '../' prefix for require().
|
||||||
|
function relativeRequire (fromDir, targetFile) {
|
||||||
|
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
|
||||||
|
if (!rel.startsWith('.')) rel = `./${rel}`
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
function main () {
|
||||||
|
const irArg = process.argv[2]
|
||||||
|
const outArg = process.argv[3]
|
||||||
|
|
||||||
|
if (!irArg || !outArg) {
|
||||||
|
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
|
||||||
|
process.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let ir
|
||||||
|
try {
|
||||||
|
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const genDir = outArg
|
||||||
|
fs.mkdirSync(genDir, { recursive: true })
|
||||||
|
|
||||||
|
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
|
||||||
|
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
|
||||||
|
const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js'))
|
||||||
|
|
||||||
|
const body = `'use strict'
|
||||||
|
const { runFeature } = require('${relRuntime}')
|
||||||
|
const ir = ${JSON.stringify(ir, null, 2)}
|
||||||
|
async function main () {
|
||||||
|
const report = await runFeature(ir)
|
||||||
|
for (const r of report.results) {
|
||||||
|
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
|
||||||
|
if (r.detail) console.log(' ' + r.detail)
|
||||||
|
}
|
||||||
|
if (report.failures > 0) {
|
||||||
|
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main().catch((err) => { console.error(err); process.exit(1) })
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(testFile, body)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-feature metadata with an implementation hash over generated files only.
|
||||||
|
const metaDir = path.join(genDir, 'metadata')
|
||||||
|
fs.mkdirSync(metaDir, { recursive: true })
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(fs.readFileSync(testFile))
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
schema_version: 1,
|
||||||
|
feature_path: `${featureKey}.feature`,
|
||||||
|
ir_path: path.resolve(irArg),
|
||||||
|
implementation_hash: `sha256:${hash}`,
|
||||||
|
hash_scope: 'generated_files',
|
||||||
|
generated_files: [testFile]
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(metaDir, metadataName(featureKey)),
|
||||||
|
JSON.stringify(metadata, null, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/*
|
||||||
|
Project step handlers for the psf-memo-client acceptance pipeline.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||||
|
function makeWallet (address) {
|
||||||
|
const wallet = {
|
||||||
|
walletInfo: { cashAddress: address },
|
||||||
|
utxos: [],
|
||||||
|
broadcasts: [],
|
||||||
|
getUtxos: async function () {
|
||||||
|
return this.utxos
|
||||||
|
},
|
||||||
|
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||||
|
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||||
|
return 'aa'.repeat(32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake feed reflecting posts added to the recent posts feed.
|
||||||
|
function makeFeed () {
|
||||||
|
const posts = []
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
addPost: (post) => posts.push(post)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh world/state object for a single scenario execution.
|
||||||
|
function createWorld () {
|
||||||
|
const wallet = makeWallet('')
|
||||||
|
const feed = makeFeed()
|
||||||
|
const memoPost = new MemoPost({ wallet, feed })
|
||||||
|
const world = {
|
||||||
|
wallet,
|
||||||
|
feed,
|
||||||
|
memoPost,
|
||||||
|
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, step).
|
||||||
|
const handlers = [
|
||||||
|
{
|
||||||
|
name: 'wallet authenticated for address',
|
||||||
|
pattern: /^a wallet authenticated for the address (.+)$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
world.wallet.walletInfo.cashAddress = m[1].trim()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'wallet has spendable output',
|
||||||
|
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 }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'viewing recent posts feed',
|
||||||
|
pattern: /^I am viewing the recent posts feed$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
world.currentPath = NewPostPage.RECENT_FEED_PATH
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.newPage.setInput(example[param])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'submit/click post',
|
||||||
|
pattern: /^I (?:submit the memo|click the post button)$/,
|
||||||
|
async run (m, example, world) {
|
||||||
|
await world.newPage.submit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'broadcasts OP_RETURN with 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) {
|
||||||
|
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||||
|
}
|
||||||
|
const last = broadcasts[broadcasts.length - 1]
|
||||||
|
if (last.prefix !== MEMO_POST_PREFIX) {
|
||||||
|
throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`)
|
||||||
|
}
|
||||||
|
if (last.msg !== world.newPage.input) {
|
||||||
|
throw new Error('Broadcast message text did not match the composed memo.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feed shows new post from my address',
|
||||||
|
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
|
||||||
|
run (m, example, world) {
|
||||||
|
const param = m[1]
|
||||||
|
const expectedText = example[param]
|
||||||
|
const myAddress = world.wallet.walletInfo.cashAddress
|
||||||
|
const found = world.feed.posts.find(
|
||||||
|
(p) => p.text === expectedText && p.address === myAddress
|
||||||
|
)
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Feed does not show the new post with text "${expectedText}".`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.newPage.submitError !== expectedCode) {
|
||||||
|
throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Route a single step to its handler. Throws on unsupported step text.
|
||||||
|
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, step)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWorld, handleStep }
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
Persistent runner adapter for the APS gherkin-mutator.
|
||||||
|
|
||||||
|
The mutator starts this process (once per worker) and sends mutation jobs
|
||||||
|
over newline-delimited JSON on stdin. Each job carries the path to a mutated
|
||||||
|
feature JSON IR; this worker evaluates it through the same acceptance runtime
|
||||||
|
and step handlers used by the normal acceptance pipeline and replies with the
|
||||||
|
runner outcome.
|
||||||
|
|
||||||
|
Protocol (mutator-spec.md):
|
||||||
|
request: { "id", "feature_json", "generated_dir", "work_dir" }
|
||||||
|
response: { "id", "outcome", "output", "error", "duration" }
|
||||||
|
outcome: test_success | test_failure | infrastructure_error
|
||||||
|
|
||||||
|
test_failure (acceptance failed) -> mutation killed
|
||||||
|
test_success (acceptance passed) -> mutation survived
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const readline = require('node:readline')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const { runFeature } = require('./runtime')
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
terminal: false
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.on('line', async (line) => {
|
||||||
|
const started = Date.now()
|
||||||
|
const respond = (payload) => {
|
||||||
|
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let job
|
||||||
|
try {
|
||||||
|
job = JSON.parse(line)
|
||||||
|
} catch (err) {
|
||||||
|
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
|
||||||
|
const report = await runFeature(ir)
|
||||||
|
respond({
|
||||||
|
id: job.id,
|
||||||
|
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
|
||||||
|
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
|
||||||
|
error: '',
|
||||||
|
duration: Date.now() - started
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
respond({
|
||||||
|
id: job.id,
|
||||||
|
outcome: 'infrastructure_error',
|
||||||
|
output: '',
|
||||||
|
error: err.message,
|
||||||
|
duration: Date.now() - started
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/*
|
||||||
|
Acceptance runtime for psf-memo-client.
|
||||||
|
|
||||||
|
Expands each scenario (and each example row) from parser JSON IR into
|
||||||
|
scenario executions, prepends background steps, and dispatches every step to
|
||||||
|
the project step handlers. Unsupported steps, invalid example values, or
|
||||||
|
failed assertions fail that execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const { createWorld, handleStep } = require('./handlers')
|
||||||
|
|
||||||
|
// Expand the IR scenarios into concrete executions.
|
||||||
|
// For scenario outlines with examples, one execution per example row; for
|
||||||
|
// scenarios without examples, one execution with an empty example store.
|
||||||
|
function expandScenarios (ir) {
|
||||||
|
const executions = []
|
||||||
|
const background = ir.background || []
|
||||||
|
|
||||||
|
for (const scenario of ir.scenarios) {
|
||||||
|
const examples = (scenario.examples && scenario.examples.length > 0)
|
||||||
|
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
|
||||||
|
: [{ example: {}, suffix: 'example_1' }]
|
||||||
|
|
||||||
|
for (const { example, suffix } of examples) {
|
||||||
|
executions.push({
|
||||||
|
name: `${scenario.name}/${suffix}`,
|
||||||
|
steps: [...background, ...scenario.steps],
|
||||||
|
example
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a full feature and return a report of scenario outcomes.
|
||||||
|
async function runFeature (ir) {
|
||||||
|
const executions = expandScenarios(ir)
|
||||||
|
const results = []
|
||||||
|
let failures = 0
|
||||||
|
|
||||||
|
for (const ex of executions) {
|
||||||
|
// A fresh world/state object for each scenario execution.
|
||||||
|
const world = createWorld()
|
||||||
|
let failure = null
|
||||||
|
|
||||||
|
for (const step of ex.steps) {
|
||||||
|
try {
|
||||||
|
await handleStep(step, ex.example, world)
|
||||||
|
} catch (err) {
|
||||||
|
failure = err.message
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
failures++
|
||||||
|
results.push({ name: ex.name, status: 'failed', detail: failure })
|
||||||
|
} else {
|
||||||
|
results.push({ name: ex.name, status: 'passed' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { feature: ir.name, results, failures, total: results.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { expandScenarios, runFeature }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Architectural Review Summary — new-post-page
|
||||||
|
|
||||||
|
## Task and commits reviewed
|
||||||
|
- Task: `new-post-page`
|
||||||
|
- Reviewed the merged branch ending at `d57975c466` (refactorer), which carried:
|
||||||
|
- `9385034`/`4fa92d1` — specifier New Post Page Gherkin spec (`specs/memo-new.feature`)
|
||||||
|
- `05f393c` — coder implementation (`src/services/new-post.js`, page wiring)
|
||||||
|
- `d57975c` — refactorer property tests for memo post / new post invariants
|
||||||
|
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
|
||||||
|
|
||||||
|
## Architectural findings and fixes applied
|
||||||
|
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||||
|
local code quality.
|
||||||
|
|
||||||
|
1. **Testable controller behind adapters (good).** `src/services/new-post.js` is a
|
||||||
|
testable controller wrapping `memo-post.js`; it holds draft input, the remaining-
|
||||||
|
character counter, typed validation/length errors, and feed navigation. `memoPost`
|
||||||
|
and `navigate` are injected so the module stays free of UI/IO concerns. Dependency
|
||||||
|
direction is inward.
|
||||||
|
2. **Unified acceptance handlers (good).** `acceptance/lib/handlers.js` now drives both
|
||||||
|
`post-memo.feature` and `memo-new.feature` through one `world.newPage`, reusing the
|
||||||
|
shared Memo post behavior. Wording differences are normalized via shared regex
|
||||||
|
alternations; no step logic is duplicated.
|
||||||
|
3. **Property tests (good).** `test/property/{harness,memo-post.property.test}.js`
|
||||||
|
assert seeded invariants (validation classification across the length boundary,
|
||||||
|
counter conservation `remaining === MAX - len`, `setInput` round-trip, menu-link
|
||||||
|
idempotence). Kept separate from unit tests as the architecture requires.
|
||||||
|
4. **Fix applied — posting-state coverage.** The language mutation tool surfaced 4
|
||||||
|
survivors in `new-post.js`, all around the `posting` state flag (initial value and
|
||||||
|
its true/false transitions in `submit`). Added unit coverage asserting the page
|
||||||
|
starts idle, is `posting=true` while a submit is in flight (via a deferred wallet),
|
||||||
|
and returns to `posting=false` on success and on error. This killed all 4 survivors.
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
- **Unit (`node --test`):** 18/18 pass (added 2 posting-state tests).
|
||||||
|
- **Property (`npm run test:property`):** 6/6 pass.
|
||||||
|
- **Acceptance (normal):** both `memo-new` and `post-memo` generated suites pass.
|
||||||
|
- **Mutation (`mutate4javascript`, `--max-workers 8`):**
|
||||||
|
- `memo-post.js`: differential reuse (module unchanged since its 7/7 kill).
|
||||||
|
- `new-post.js`: after coverage fix, **killed 11 / survived 0 / uncovered 0**
|
||||||
|
(was 4 survivors before the fix).
|
||||||
|
- **DRY (`dry4javascript src`):** no duplicate candidates.
|
||||||
|
- **Gherkin acceptance mutation (soft):**
|
||||||
|
- `memo-new.feature`: 11 executed — **5 killed, 6 survived**, 0 errors.
|
||||||
|
- Killed: empty-memo boundary; character-counter `count` example values (217/212/0)
|
||||||
|
— proving those values are connected to behavior.
|
||||||
|
- Survived (documented equivalents): message-content dithers (4) plus case/length-
|
||||||
|
neutral dithers in the counter scenario — message text is opaque data; only length
|
||||||
|
affects the counter, so these don't change observable behavior.
|
||||||
|
- `post-memo.feature`: empty-memo scenario reused as killed; remaining 5 executed are
|
||||||
|
the same documented message-content equivalents.
|
||||||
|
- Property tests: run separately via `npm run test:property`.
|
||||||
|
|
||||||
|
## Suite status
|
||||||
|
- Unit + property + acceptance all pass; source-level mutation fully kills both testable
|
||||||
|
core modules. Gherkin acceptance mutation survivors are documented equivalents.
|
||||||
|
|
||||||
|
## Handoffs sent
|
||||||
|
- `git_handoff` → coder, refactorer (priority `00`, task `new-post-page`), to review the
|
||||||
|
architect commit (posting-state coverage + tool manifests).
|
||||||
|
|
||||||
|
By architect.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Architectural Review Summary — post-memo
|
||||||
|
|
||||||
|
## Task and commits reviewed
|
||||||
|
- Task: `post-memo`
|
||||||
|
- Reviewed the merged branch ending at `8e2693ad08` (refactorer), which carried:
|
||||||
|
- `d071b21`/`c9556be` — specifier Post-a-Memo Gherkin spec + feature backlog
|
||||||
|
- `514298b` — coder implementation with acceptance pipeline
|
||||||
|
- `64a7f78` — refactorer merge of coder work
|
||||||
|
- `8e2693a` — refactorer memo-post complexity reduction
|
||||||
|
- Merged into `swarmforge-architect` (fast-forward) and processed as a batch.
|
||||||
|
|
||||||
|
## Architectural findings and fixes applied
|
||||||
|
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||||
|
local code quality.
|
||||||
|
|
||||||
|
1. **Testable core behind small adapters (good).** `src/services/memo-post.js` is a
|
||||||
|
testable module free of UI/IO concerns. It injects `wallet` and `feed` adapters
|
||||||
|
(minimal-slp-wallet surface + feed reflection), so OP_RETURN/broadcast and UI
|
||||||
|
concerns stay outside the core. Environmentally unsuitable I/O is confined to
|
||||||
|
adapter boundaries. Dependency direction is inward.
|
||||||
|
2. **Acceptance pipeline separation (good).** `acceptance/lib/{generate,runtime,handlers}`
|
||||||
|
are the project-specific components the APS spec prescribes. The feature is parsed
|
||||||
|
by the **APS-supplied Babashka `gherkin-parser`** (procured fresh from
|
||||||
|
github.com/unclebob/Acceptance-Pipeline-Specification on first use), not
|
||||||
|
reimplemented. `handlers.js` drives real core behavior through fake wallet/feed
|
||||||
|
adapters so the run is deterministic and offline.
|
||||||
|
3. **Information hiding (good).** memo-post hides the Memo `0x6d02` prefix and
|
||||||
|
broadcast mechanics; handlers only assert observable outcomes (broadcast prefix,
|
||||||
|
feed reflection, empty/length rejection).
|
||||||
|
4. **Fix applied — runner adapter added.** Built the project-specific persistent
|
||||||
|
runner adapter required by `gherkin-mutator` at
|
||||||
|
`acceptance/lib/runner-worker.js` (newline-delimited JSON protocol; evaluates each
|
||||||
|
mutated feature IR through the same runtime/handlers and reports
|
||||||
|
`test_failure|test_success|infrastructure_error`).
|
||||||
|
5. **Manifests updated by tools (permitted).** The language mutation tool embedded
|
||||||
|
its differential footer manifest in `src/services/memo-post.js`; the APS mutator
|
||||||
|
wrote the acceptance-mutation scenario manifest into `specs/post-memo.feature`.
|
||||||
|
Both are normal tool output and were left for the tooling, not hand-edited.
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
- **Unit (`node --test`):** 7/7 pass (`memo-post.test.js`).
|
||||||
|
- **Acceptance (normal):** all 6 scenario executions pass.
|
||||||
|
- **Mutation (`mutate4javascript` differential, `--max-workers 8`):**
|
||||||
|
`memo-post.js` — **killed 7, survived 0, uncovered 0**. Full kill.
|
||||||
|
- **DRY (`dry4javascript src`):** no duplicate candidates.
|
||||||
|
- **Gherkin acceptance mutation (soft):** 6 discovered; **1 killed**, **5 survived**,
|
||||||
|
0 errors.
|
||||||
|
- Killed: the empty-memo boundary — dithering the `" "` example makes the
|
||||||
|
validation branch fail the scenario, confirming the empty-rejection guardrail is
|
||||||
|
connected to the example data.
|
||||||
|
- Survived (documented equivalents): message-content dithers in the valid-memo
|
||||||
|
scenario and the over-long scenarios. The tests treat the message as opaque data
|
||||||
|
and assert the *same* (mutated) text is composed and reflected, so changing one
|
||||||
|
character leaves the exercised branch identical. These are acceptable equivalent
|
||||||
|
survivors, not missing guardrails.
|
||||||
|
- **Property tests:** none present in this project.
|
||||||
|
|
||||||
|
## Suite status
|
||||||
|
- Unit + acceptance suites pass; mutation fully kills source-level mutants for the
|
||||||
|
testable core. Gherkin acceptance mutation has 5 documented-equivalent survivors.
|
||||||
|
|
||||||
|
## Handoffs sent
|
||||||
|
- `git_handoff` → coder, refactorer (priority `00`, task `post-memo`), to review the
|
||||||
|
architectural commit (runner adapter + tool manifests).
|
||||||
|
|
||||||
|
By architect.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Architectural Review Summary — reduce-dry-duplication
|
||||||
|
|
||||||
|
## Task and commits reviewed
|
||||||
|
- Task: `reduce-dry-duplication`
|
||||||
|
- Source commit merged and reviewed: `eed9d70f6f` (`Reduce DRY duplication in services and components`, from refactorer)
|
||||||
|
- Reviewed as a batch via `ready_for_next.sh`; merged into `swarmforge-architect` as a fast-forward and processed.
|
||||||
|
|
||||||
|
## Architectural findings and fixes applied
|
||||||
|
Reviewed the refactorer's DRY reduction for UI/Core separation, dependency rule,
|
||||||
|
information hiding/encapsulation, and local code quality.
|
||||||
|
|
||||||
|
Findings (mostly sound, two notes):
|
||||||
|
1. **MemoDb service consolidation** (`src/services/memo-db.js`) — `getRecentProfiles`/
|
||||||
|
`getRecentPosts` → `getRecent`, and `getProfile`/`getProfilePic`/`getName` →
|
||||||
|
`getLevelResource`. Preserves error semantics (recent → always throws; level → null
|
||||||
|
on 404). Good cohesion, HTTP details stay in the service. **No change needed.**
|
||||||
|
2. **AppUtil `pasteFromClipboard`** (`src/util/index.js`) — centralizes clipboard-paste
|
||||||
|
into the shared utility; components no longer duplicate clipboard reading. Good
|
||||||
|
information hiding. **No change needed.**
|
||||||
|
3. **`PlaceholderView` extraction** — shared placeholder used by placeholder2/3.
|
||||||
|
Cohesive. **No change needed.**
|
||||||
|
4. **`wallet-summary` blur toggle generalization** — `toggleBlur(field)` via dynamic
|
||||||
|
setter name reduces duplication. Functional and correct; the dynamic
|
||||||
|
`set${Capitalized}` construction is a mild readability tradeoff but acceptable to
|
||||||
|
preserve the DRY intent. **No change.**
|
||||||
|
5. **Cross-feature dependency (fixed)** — `recent-profiles` imported the general-purpose
|
||||||
|
`truncateAddr`/`truncateTxid` from the feed-specific `post-feed/post-display`.
|
||||||
|
For cohesion and dependency direction, these pure string helpers belong in the
|
||||||
|
shared utility layer. **Applied fix:** moved `truncateAddr`/`truncateTxid` to
|
||||||
|
`src/util/index.js`; `post-display` now imports from util and re-exports them for
|
||||||
|
its existing consumers; `recent-profiles` imports directly from `src/util`.
|
||||||
|
This removes a sibling-feature → sibling-feature module coupling and keeps generic
|
||||||
|
display helpers in the shared utility.
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
- **Mutation** (`mutate4javascript --scan`): memo-db.js 4 sites, util/index.js 6 sites,
|
||||||
|
post-display.js 8 sites. No JS unit test suite exists in this project
|
||||||
|
(`npm test` → `echo 'no tests'`); there is no coverage harness, so all sites are
|
||||||
|
uncovered and no survivors can be killed by tests. JSX component files cannot be
|
||||||
|
parsed by the mutation tool (no `jsx` Babel plugin); they are UI adapters and are
|
||||||
|
outside the currently testable boundary.
|
||||||
|
- **DRY** (`dry4javascript src`): **no duplicate candidates found** — the reduction
|
||||||
|
is effective and my fix introduced no duplication.
|
||||||
|
- **Gherkin acceptance mutation (soft)**: not runnable — this project has no
|
||||||
|
`.feature` files, no acceptance pipeline, and no runner adapter.
|
||||||
|
- Property tests: none present in this project.
|
||||||
|
|
||||||
|
## Suite status
|
||||||
|
- No JavaScript unit/acceptance suite exists (`npm test` echoes "no tests").
|
||||||
|
- `bb.edn` test task covers the swarmforge helper scripts only (unrelated to this UI work).
|
||||||
|
|
||||||
|
## Handoffs sent
|
||||||
|
- `git_handoff` → coder, refactorer (priority `00`, task `reduce-dry-duplication`),
|
||||||
|
to review the architectural commit.
|
||||||
|
|
||||||
|
By architect.
|
||||||
+3
-1
@@ -25,7 +25,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"test": "echo 'no tests'",
|
"test": "node --test \"test/unit/*.test.js\"",
|
||||||
|
"test:property": "node --test \"test/property/*.test.js\"",
|
||||||
|
"test:acceptance": "node acceptance/acceptance.js",
|
||||||
"eject": "react-scripts eject",
|
"eject": "react-scripts eject",
|
||||||
"lint": "standard --env mocha --fix",
|
"lint": "standard --env mocha --fix",
|
||||||
"pub": "node deploy/publish-main.js",
|
"pub": "node deploy/publish-main.js",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# acceptance-mutation-manifest-begin
|
||||||
|
# {"version":1,"tested_at":"2026-08-26T00:08:15.433121898Z","feature_name":"New Post Page","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T00:08:15.433121898Z"}]}
|
||||||
|
# acceptance-mutation-manifest-end
|
||||||
|
|
||||||
# Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5
|
# Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5
|
||||||
Feature: New Post Page
|
Feature: New Post Page
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
# acceptance-mutation-manifest-begin
|
||||||
|
# {"version":1,"tested_at":"2026-08-26T00:08:35.176401424Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]}
|
||||||
|
# acceptance-mutation-manifest-end
|
||||||
|
|
||||||
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
|
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
|
||||||
Feature: Post a Memo
|
Feature: Post a Memo
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,7 @@ const WalletImport = (props) => {
|
|||||||
const { appData } = props
|
const { appData } = props
|
||||||
|
|
||||||
// Load mnemonic from clipboard
|
// Load mnemonic from clipboard
|
||||||
const pasteFromClipboard = async () => {
|
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setNewMnemonic)
|
||||||
try {
|
|
||||||
const mnemonic = await appData.appUtil.readFromClipboard()
|
|
||||||
setNewMnemonic(mnemonic)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Error pasting from clipboard: ', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle input change for mnemonic
|
// Handle input change for mnemonic
|
||||||
const handleImportMnemonic = async (event) => {
|
const handleImportMnemonic = async (event) => {
|
||||||
|
|||||||
@@ -38,29 +38,14 @@ function WalletSummary (props) {
|
|||||||
privateKey: blurredPrivateKey ? faEyeSlash : faEye
|
privateKey: blurredPrivateKey ? faEyeSlash : faEye
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the state of blurring for the mnemonic
|
// Toggle the state of blurring for a field (mnemonic or private key)
|
||||||
const toggleMnemonicBlur = (inObj = {}) => {
|
const toggleBlur = (field) => {
|
||||||
try {
|
try {
|
||||||
const { walletSummaryData } = inObj
|
const blurredState = walletSummaryData[field]
|
||||||
|
const setterName = `set${field[0].toUpperCase()}${field.slice(1)}`
|
||||||
// toggle the state of blurring
|
walletSummaryData[setterName](!blurredState)
|
||||||
const blurredState = walletSummaryData.blurredMnemonic
|
|
||||||
walletSummaryData.setBlurredMnemonic(!blurredState)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling mnemonic blur: ', error)
|
console.error(`Error toggling ${field} blur: `, error)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle the state of blurring for the private key
|
|
||||||
const togglePrivateKeyBlur = (inObj = {}) => {
|
|
||||||
try {
|
|
||||||
const { walletSummaryData } = inObj
|
|
||||||
|
|
||||||
// toggle the state of blurring
|
|
||||||
const blurredState = walletSummaryData.blurredPrivateKey
|
|
||||||
walletSummaryData.setBlurredPrivateKey(!blurredState)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling private key blur: ', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +72,7 @@ function WalletSummary (props) {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
icon={eyeIcon.mnemonic}
|
icon={eyeIcon.mnemonic}
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => toggleMnemonicBlur({ walletSummaryData })}
|
onClick={() => toggleBlur('blurredMnemonic')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||||
@@ -104,7 +89,7 @@ function WalletSummary (props) {
|
|||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
icon={eyeIcon.privateKey}
|
icon={eyeIcon.privateKey}
|
||||||
size='lg'
|
size='lg'
|
||||||
onClick={() => togglePrivateKeyBlur({ walletSummaryData })}
|
onClick={() => toggleBlur('blurredPrivateKey')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import ServerSelectView from './configuration/select-server-view'
|
|||||||
import UserDataReview from './user-data-review'
|
import UserDataReview from './user-data-review'
|
||||||
import RecentProfiles from './recent-profiles'
|
import RecentProfiles from './recent-profiles'
|
||||||
import RecentPosts from './posts'
|
import RecentPosts from './posts'
|
||||||
|
import NewPost from './new-post'
|
||||||
import Profile from './profile'
|
import Profile from './profile'
|
||||||
|
|
||||||
function AppBody (props) {
|
function AppBody (props) {
|
||||||
@@ -42,6 +43,7 @@ function AppBody (props) {
|
|||||||
<Route path='/profile/recent' element={<RecentProfiles />} />
|
<Route path='/profile/recent' element={<RecentProfiles />} />
|
||||||
<Route path='/profile/:addr' element={<Profile />} />
|
<Route path='/profile/:addr' element={<Profile />} />
|
||||||
<Route path='/posts/recent' element={<RecentPosts />} />
|
<Route path='/posts/recent' element={<RecentPosts />} />
|
||||||
|
<Route path='/posts/new' element={<NewPost appData={appData} />} />
|
||||||
<Route path='/placeholder2' element={<Placeholder2 />} />
|
<Route path='/placeholder2' element={<Placeholder2 />} />
|
||||||
<Route path='/placeholder3' element={<Placeholder3 />} />
|
<Route path='/placeholder3' element={<Placeholder3 />} />
|
||||||
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
|
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
New Post view: compose and broadcast a Memo post, with a character counter
|
||||||
|
that counts down from the memo limit. On success the user is navigated to the
|
||||||
|
recent feed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Global npm libraries
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Container, Row, Col, Form, Button } from 'react-bootstrap'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
// Local libraries
|
||||||
|
import MemoPost from '../../../services/memo-post'
|
||||||
|
import NewPostPage from '../../../services/new-post'
|
||||||
|
|
||||||
|
function NewPost (props) {
|
||||||
|
const { appData } = props
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const maxChars = MemoPost.MAX_MEMO_CHARS
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [posting, setPosting] = useState(false)
|
||||||
|
|
||||||
|
const remaining = maxChars - input.length
|
||||||
|
|
||||||
|
async function handleSubmit (event) {
|
||||||
|
event.preventDefault()
|
||||||
|
setErr('')
|
||||||
|
setPosting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const memoPost = new MemoPost({ wallet: appData?.wallet })
|
||||||
|
const page = new NewPostPage({ memoPost, navigate })
|
||||||
|
page.setInput(input)
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
if (!result.ok) {
|
||||||
|
setErr(
|
||||||
|
result.error === 'memo_length'
|
||||||
|
? `Memo is too long. Maximum is ${maxChars} characters.`
|
||||||
|
: 'Memo must not be empty.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// On success page.submit() navigated to the recent feed.
|
||||||
|
} catch (submitErr) {
|
||||||
|
setErr(submitErr.message)
|
||||||
|
} finally {
|
||||||
|
setPosting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Row className='justify-content-center'>
|
||||||
|
<Col lg={8} md={10} xs={12}>
|
||||||
|
<header className='new-post-heading'>
|
||||||
|
<h1>New Post</h1>
|
||||||
|
<p>Compose a Memo message and publish it to Bitcoin Cash.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Group controlId='new-post-message' className='mb-3'>
|
||||||
|
<Form.Label><b>Message</b></Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as='textarea'
|
||||||
|
rows={6}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder='Write your Memo here...'
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<p className='new-post-counter'>
|
||||||
|
{remaining} characters remaining
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{err && <p className='new-post-error'>{err}</p>}
|
||||||
|
|
||||||
|
<Button type='submit' variant='primary' disabled={posting}>
|
||||||
|
{posting ? 'Posting...' : 'Post'}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewPost
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
A placeholder view used for unreviewed routes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Global npm libraries
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
|
||||||
|
function PlaceholderView (props) {
|
||||||
|
const { viewNumber } = props
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`Placeholder ${viewNumber} loaded.`)
|
||||||
|
}, [viewNumber])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p style={{ padding: '25px' }}>This is placeholder View #{viewNumber}</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlaceholderView
|
||||||
@@ -2,19 +2,11 @@
|
|||||||
This is a placeholder View
|
This is a placeholder View
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global npm libraries
|
// Local libraries
|
||||||
import React, { useEffect } from 'react'
|
import PlaceholderView from './placeholder-view'
|
||||||
|
|
||||||
function Placeholder2 (props) {
|
function Placeholder2 (props) {
|
||||||
useEffect(() => {
|
return <PlaceholderView viewNumber={2} />
|
||||||
console.log('Placeholder 2 loaded.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Placeholder2
|
export default Placeholder2
|
||||||
|
|||||||
@@ -2,19 +2,11 @@
|
|||||||
This is a placeholder View
|
This is a placeholder View
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Global npm libraries
|
// Local libraries
|
||||||
import React, { useEffect } from 'react'
|
import PlaceholderView from './placeholder-view'
|
||||||
|
|
||||||
function Placeholder3 (props) {
|
function Placeholder3 (props) {
|
||||||
useEffect(() => {
|
return <PlaceholderView viewNumber={3} />
|
||||||
console.log('Placeholder 3 loaded.')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Placeholder3
|
export default Placeholder3
|
||||||
|
|||||||
@@ -8,17 +8,11 @@ import { Container, Row, Col, Spinner, Table } from 'react-bootstrap'
|
|||||||
|
|
||||||
// Local libraries
|
// Local libraries
|
||||||
import MemoDb from '../../../services/memo-db'
|
import MemoDb from '../../../services/memo-db'
|
||||||
import AppUtil from '../../../util'
|
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
|
||||||
import '../../../App.css'
|
import '../../../App.css'
|
||||||
|
|
||||||
const appUtil = new AppUtil()
|
const appUtil = new AppUtil()
|
||||||
|
|
||||||
function truncate (str, maxLen = 16) {
|
|
||||||
if (!str || str.length <= maxLen) return str
|
|
||||||
const half = Math.floor((maxLen - 3) / 2)
|
|
||||||
return `${str.slice(0, half)}...${str.slice(-half)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSeen (seen) {
|
function formatSeen (seen) {
|
||||||
if (!seen) return ''
|
if (!seen) return ''
|
||||||
const ms = seen > 1e12 ? seen : seen * 1000
|
const ms = seen > 1e12 ? seen : seen * 1000
|
||||||
@@ -88,7 +82,7 @@ function RecentProfiles () {
|
|||||||
style={{ fontFamily: 'monospace' }}
|
style={{ fontFamily: 'monospace' }}
|
||||||
title={profile.addr}
|
title={profile.addr}
|
||||||
>
|
>
|
||||||
{truncate(profile.addr, 24)}
|
{truncateAddr(profile.addr, 24)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{profile.text}</td>
|
<td>{profile.text}</td>
|
||||||
@@ -100,7 +94,7 @@ function RecentProfiles () {
|
|||||||
title={profile.txid}
|
title={profile.txid}
|
||||||
onClick={() => appUtil.copyToClipboard(profile.txid)}
|
onClick={() => appUtil.copyToClipboard(profile.txid)}
|
||||||
>
|
>
|
||||||
{truncate(profile.txid, 20)}
|
{truncateTxid(profile.txid, 20)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -105,14 +105,7 @@ function SendTokenButton ({ token, appData, refreshTokens }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load address from clipboard
|
// Load address from clipboard
|
||||||
const pasteFromClipboard = async () => {
|
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setSendToAddress)
|
||||||
try {
|
|
||||||
const address = await appData.appUtil.readFromClipboard()
|
|
||||||
setSendToAddress(address)
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Error pasting from clipboard: ', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal JSX
|
// Modal JSX
|
||||||
const getModal = () => {
|
const getModal = () => {
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ function NavMenu (props) {
|
|||||||
Posts
|
Posts
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||||
|
to='/posts/new'
|
||||||
|
onClick={handleClickEvent}
|
||||||
|
>
|
||||||
|
New Post
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
|
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||||
to='/wallet'
|
to='/wallet'
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
Shared display helpers for post feed and thread views.
|
Shared display helpers for post feed and thread views.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { truncateAddr } from '../../util'
|
||||||
|
|
||||||
|
export { truncateAddr, truncateTxid } from '../../util'
|
||||||
|
|
||||||
export function formatRelativeSeen (seen) {
|
export function formatRelativeSeen (seen) {
|
||||||
if (!seen) return ''
|
if (!seen) return ''
|
||||||
const ms = seen > 1e12 ? seen : seen * 1000
|
const ms = seen > 1e12 ? seen : seen * 1000
|
||||||
@@ -26,16 +30,6 @@ export function formatRelativeSeen (seen) {
|
|||||||
return `${years}y`
|
return `${years}y`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function truncateAddr (addr, maxLen = 20) {
|
|
||||||
if (!addr || addr.length <= maxLen) return addr
|
|
||||||
const half = Math.floor((maxLen - 3) / 2)
|
|
||||||
return `${addr.slice(0, half)}...${addr.slice(-half)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function truncateTxid (txid, maxLen = 20) {
|
|
||||||
return truncateAddr(txid, maxLen)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDisplayName (addr, profiles) {
|
export function getDisplayName (addr, profiles) {
|
||||||
const profile = profiles?.[addr]
|
const profile = profiles?.[addr]
|
||||||
if (profile?.name) {
|
if (profile?.name) {
|
||||||
|
|||||||
+25
-46
@@ -11,72 +11,51 @@ class MemoDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRecentProfiles ({ limit = 100, offset = 0 } = {}) {
|
async getRecentProfiles ({ limit = 100, offset = 0 } = {}) {
|
||||||
try {
|
return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset })
|
||||||
const result = await this.axios.get(`${config.backend}/profile/recent`, {
|
|
||||||
params: { limit, offset }
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error in getRecentProfiles()')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
||||||
|
return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile (addr) {
|
||||||
|
return this.getLevelResource('profile', addr, 'getProfile')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfilePic (addr) {
|
||||||
|
return this.getLevelResource('profilepic', addr, 'getProfilePic')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getName (addr) {
|
||||||
|
return this.getLevelResource('name', addr, 'getName')
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET a paginated 'recent' listing endpoint.
|
||||||
|
async getRecent (path, name, params) {
|
||||||
try {
|
try {
|
||||||
const result = await this.axios.get(`${config.backend}/posts/recent`, {
|
const result = await this.axios.get(`${config.backend}${path}`, {
|
||||||
params: { limit, offset }
|
params
|
||||||
})
|
})
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in getRecentPosts()')
|
console.error(`Error in ${name}()`)
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProfile (addr) {
|
// GET a level endpoint that resolves an address. Returns null on 404.
|
||||||
|
async getLevelResource (endpoint, addr, name) {
|
||||||
try {
|
try {
|
||||||
const result = await this.axios.get(
|
const result = await this.axios.get(
|
||||||
`${config.backend}/level/profile/${encodeURIComponent(addr)}`
|
`${config.backend}/level/${endpoint}/${encodeURIComponent(addr)}`
|
||||||
)
|
)
|
||||||
return result.data
|
return result.data
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response && err.response.status === 404) {
|
if (err.response && err.response.status === 404) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
console.error('Error in getProfile()')
|
console.error(`Error in ${name}()`)
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProfilePic (addr) {
|
|
||||||
try {
|
|
||||||
const result = await this.axios.get(
|
|
||||||
`${config.backend}/level/profilepic/${encodeURIComponent(addr)}`
|
|
||||||
)
|
|
||||||
return result.data
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response && err.response.status === 404) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.error('Error in getProfilePic()')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getName (addr) {
|
|
||||||
try {
|
|
||||||
const result = await this.axios.get(
|
|
||||||
`${config.backend}/level/name/${encodeURIComponent(addr)}`
|
|
||||||
)
|
|
||||||
return result.data
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response && err.response.status === 404) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
console.error('Error in getName()')
|
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
Memo post behavior: compose, validate, and broadcast a Memo "post" message.
|
||||||
|
|
||||||
|
A Memo post is an OP_RETURN Bitcoin Cash transaction carrying the Memo post
|
||||||
|
protocol prefix (0x6d02) followed by the message text. Broadcasting is done
|
||||||
|
through a wallet that exposes the minimal-slp-wallet adapter surface
|
||||||
|
(walletInfo, getUtxos(), sendOpReturn()).
|
||||||
|
|
||||||
|
The wallet and feed are injected so this module stays testable and free of
|
||||||
|
network/UI concerns; environmentally unsuitable I/O lives behind those small
|
||||||
|
adapter boundaries.
|
||||||
|
|
||||||
|
Constants
|
||||||
|
MEMO_POST_PREFIX : hex prefix for the Memo "post" action (0x6d02)
|
||||||
|
MAX_MEMO_CHARS : maximum allowed memo length (spec boundary: 217 valid,
|
||||||
|
218 rejected)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MEMO_POST_PREFIX = '6d02'
|
||||||
|
const MAX_MEMO_CHARS = 217
|
||||||
|
|
||||||
|
class MemoPost {
|
||||||
|
constructor (deps = {}) {
|
||||||
|
this.wallet = deps.wallet
|
||||||
|
this.feed = deps.feed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a candidate memo message.
|
||||||
|
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
||||||
|
validate (message) {
|
||||||
|
if (typeof message !== 'string' || message.trim().length === 0) {
|
||||||
|
return { ok: false, type: 'validation' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > MAX_MEMO_CHARS) {
|
||||||
|
return { ok: false, type: 'length' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose and broadcast a Memo post for the given message.
|
||||||
|
// Resolves with the transaction id, or rejects with a typed error.
|
||||||
|
async post (message) {
|
||||||
|
const check = this.validate(message)
|
||||||
|
this._throwIfInvalid(check)
|
||||||
|
|
||||||
|
if (!this.wallet) {
|
||||||
|
throw new Error('Memo post requires a wallet.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spendable outputs used to pay the transaction fee.
|
||||||
|
const bchUtxos = await this.wallet.getUtxos()
|
||||||
|
|
||||||
|
// Broadcast the OP_RETURN transaction with the Memo post prefix.
|
||||||
|
const txid = await this.wallet.sendOpReturn(
|
||||||
|
this.wallet.walletInfo,
|
||||||
|
bchUtxos,
|
||||||
|
message,
|
||||||
|
MEMO_POST_PREFIX
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reflect the new post in the feed once broadcast succeeds.
|
||||||
|
this._reflectPost(txid, message)
|
||||||
|
|
||||||
|
return txid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw the appropriate typed error when a memo fails validation.
|
||||||
|
_throwIfInvalid (check) {
|
||||||
|
if (check.ok) return
|
||||||
|
|
||||||
|
const err = new Error(
|
||||||
|
check.type === 'length'
|
||||||
|
? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.`
|
||||||
|
: 'Memo must not be empty.'
|
||||||
|
)
|
||||||
|
err.code = check.type === 'length' ? 'memo_length' : 'memo_validation'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the new post on the injected feed when one is present.
|
||||||
|
_reflectPost (txid, message) {
|
||||||
|
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||||
|
this.feed.addPost({
|
||||||
|
txid,
|
||||||
|
address: this.wallet.walletInfo.cashAddress,
|
||||||
|
text: message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX
|
||||||
|
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
|
||||||
|
|
||||||
|
module.exports = MemoPost
|
||||||
|
|
||||||
|
// mutate4javascript-manifest-begin
|
||||||
|
// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]}
|
||||||
|
// mutate4javascript-manifest-end
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
New Post Page behavior: compose and post a Memo, with a character counter
|
||||||
|
that counts down from the memo limit.
|
||||||
|
|
||||||
|
This is the testable controller behind the React "New Post" page. It wraps
|
||||||
|
the Memo post behavior (src/services/memo-post.js) and adds page-level
|
||||||
|
concerns: holding the current input, computing the remaining character count,
|
||||||
|
surfacing validation/length errors, and navigating to the recent feed after a
|
||||||
|
successful post.
|
||||||
|
|
||||||
|
The memoPost and navigate concerns are injected so this module stays free of
|
||||||
|
UI/network concerns; environmentally unsuitable I/O lives behind those small
|
||||||
|
adapter boundaries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MemoPost = require('./memo-post')
|
||||||
|
|
||||||
|
const NEW_POST_PATH = '/posts/new'
|
||||||
|
const RECENT_FEED_PATH = '/posts/recent'
|
||||||
|
|
||||||
|
class NewPostPage {
|
||||||
|
constructor (deps = {}) {
|
||||||
|
this.memoPost = deps.memoPost || null
|
||||||
|
this.navigate = deps.navigate || (() => {})
|
||||||
|
this.menuLinks = deps.menuLinks || []
|
||||||
|
|
||||||
|
this.input = ''
|
||||||
|
this.submitError = null
|
||||||
|
this.posting = false
|
||||||
|
|
||||||
|
// The navigation menu links to the new post page.
|
||||||
|
this.addMenuLink(NEW_POST_PATH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record a navigation menu link offered by the app.
|
||||||
|
addMenuLink (path) {
|
||||||
|
if (!this.menuLinks.includes(path)) this.menuLinks.push(path)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the navigation menu exposes a link to the given path.
|
||||||
|
hasMenuLink (path) {
|
||||||
|
return this.menuLinks.includes(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the draft memo text and update the counter.
|
||||||
|
setInput (text) {
|
||||||
|
this.input = typeof text === 'string' ? text : ''
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
// Characters remaining before the memo limit is reached.
|
||||||
|
remainingCount () {
|
||||||
|
return MemoPost.MAX_MEMO_CHARS - this.input.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and post the current draft. On success, navigate to the recent
|
||||||
|
// feed. On failure, record the typed error. Resolves with a result object.
|
||||||
|
async submit () {
|
||||||
|
this.posting = true
|
||||||
|
this.submitError = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.memoPost) {
|
||||||
|
throw new Error('New post requires a memo post handler.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const txid = await this.memoPost.post(this.input)
|
||||||
|
this.navigate(RECENT_FEED_PATH)
|
||||||
|
this.posting = false
|
||||||
|
return { ok: true, txid }
|
||||||
|
} catch (err) {
|
||||||
|
this.submitError = err.code || 'memo_validation'
|
||||||
|
this.posting = false
|
||||||
|
return { ok: false, error: this.submitError }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NewPostPage.NEW_POST_PATH = NEW_POST_PATH
|
||||||
|
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||||
|
|
||||||
|
module.exports = NewPostPage
|
||||||
|
|
||||||
|
// mutate4javascript-manifest-begin
|
||||||
|
// {"version":1,"tested_at":"2026-08-26T00:07:51.843Z","module_hash":"469dfd90342f6bcacf5b89ed819620663e221e20832f6936c35c46f9681ebfdc","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":33,"hash":"7c957fbaa2b8d4adb62c1bbf240749243696a68e71e7896aefdbb68437432cd8"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":47,"end_line":50,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":53,"end_line":55,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":59,"end_line":77,"hash":"f4ce743f5a4bb139615086165b25173641a9388af16b7527f2db243a1c6b596c"}]}
|
||||||
|
// mutate4javascript-manifest-end
|
||||||
@@ -32,6 +32,27 @@ class AppUtil {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read text from clipboard and pass it to a state setter.
|
||||||
|
async pasteFromClipboard (setValue) {
|
||||||
|
try {
|
||||||
|
const text = await this.readFromClipboard()
|
||||||
|
setValue(text)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Error pasting from clipboard: ', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate a long string (address, txid, etc.) for compact display.
|
||||||
|
export function truncateAddr (addr, maxLen = 20) {
|
||||||
|
if (!addr || addr.length <= maxLen) return addr
|
||||||
|
const half = Math.floor((maxLen - 3) / 2)
|
||||||
|
return `${addr.slice(0, half)}...${addr.slice(-half)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateTxid (txid, maxLen = 20) {
|
||||||
|
return truncateAddr(txid, maxLen)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppUtil
|
export default AppUtil
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
Small property-testing harness for psf-memo-client.
|
||||||
|
|
||||||
|
Node's built-in test runner has no property-based generator, so this module
|
||||||
|
provides a tiny deterministic, seeded pseudo-random generator plus a helper
|
||||||
|
to run a property across many samples and report a counterexample. All
|
||||||
|
generation is seeded, so runs are reproducible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// A small deterministic PRNG (mulberry32). Same seed => same stream.
|
||||||
|
function seededRandom (seed = 12345) {
|
||||||
|
let a = seed >>> 0
|
||||||
|
return function next () {
|
||||||
|
a |= 0
|
||||||
|
a = (a + 0x6d2b79f5) | 0
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a property across N samples. `gen` returns a fresh input; `check`
|
||||||
|
// returns true when the property holds. Asserts a counterexample on failure.
|
||||||
|
async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const input = gen(i)
|
||||||
|
const ok = await check(input)
|
||||||
|
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random ASCII string of a given length using a seeded RNG.
|
||||||
|
function makeStringGen (rng) {
|
||||||
|
return (length) => {
|
||||||
|
const chars = []
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
// Mix of printable ASCII (32..126).
|
||||||
|
chars.push(String.fromCharCode(32 + Math.floor(rng() * 95)))
|
||||||
|
}
|
||||||
|
return chars.join('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { seededRandom, forAll, makeStringGen }
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
Property tests for the Memo post / New Post behavior slices.
|
||||||
|
|
||||||
|
These assert useful invariants that unit tests cover only at a few fixed
|
||||||
|
points:
|
||||||
|
- Validation classification is stable across a broad input range: any
|
||||||
|
non-blank string up to the length limit is accepted, any longer string is
|
||||||
|
rejected with a length error, and blank/non-string input is a validation
|
||||||
|
error.
|
||||||
|
- The New Post character counter conserves its relationship to input
|
||||||
|
length: input.length + remainingCount() === MAX_MEMO_CHARS for any string.
|
||||||
|
- setInput round-trips the exact draft text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
|
||||||
|
const { seededRandom, forAll, makeStringGen } = require('./harness')
|
||||||
|
|
||||||
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
|
const NewPostPage = require('../../src/services/new-post')
|
||||||
|
|
||||||
|
const MAX = MemoPost.MAX_MEMO_CHARS // 217
|
||||||
|
|
||||||
|
const rng = seededRandom(20260826)
|
||||||
|
const stringOf = makeStringGen(rng)
|
||||||
|
|
||||||
|
function buildPage () {
|
||||||
|
return new NewPostPage({
|
||||||
|
memoPost: new MemoPost({}),
|
||||||
|
navigate: () => {},
|
||||||
|
menuLinks: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('memo validation: any non-blank string at or below the limit is valid', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => {
|
||||||
|
const len = 1 + Math.floor(rng() * MAX) // 1..MAX
|
||||||
|
return stringOf(len)
|
||||||
|
},
|
||||||
|
(msg) => {
|
||||||
|
// A random ASCII string may occasionally be all whitespace; whitespace-only
|
||||||
|
// input is a validation error, so only assert for non-blank strings.
|
||||||
|
if (msg.trim().length === 0) return true
|
||||||
|
const result = new MemoPost({}).validate(msg)
|
||||||
|
return result.ok === true
|
||||||
|
},
|
||||||
|
{ label: 'valid length' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('memo validation: any string above the limit is a length error', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
|
||||||
|
(msg) => {
|
||||||
|
const result = new MemoPost({}).validate(msg)
|
||||||
|
return result.ok === false && result.type === 'length'
|
||||||
|
},
|
||||||
|
{ label: 'over-long rejected as length' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('memo validation: blank and non-string input are validation errors', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => (i % 2 === 0 ? ' ' : null),
|
||||||
|
(msg) => {
|
||||||
|
const result = new MemoPost({}).validate(msg)
|
||||||
|
return result.ok === false && result.type === 'validation'
|
||||||
|
},
|
||||||
|
{ label: 'blank/non-string rejected as validation' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('character counter conserves length: remaining === MAX - input.length', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(Math.floor(rng() * (MAX + 8))),
|
||||||
|
(msg) => {
|
||||||
|
const page = buildPage()
|
||||||
|
page.setInput(msg)
|
||||||
|
return page.remainingCount() === MAX - msg.length
|
||||||
|
},
|
||||||
|
{ label: 'counter conservation' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setInput round-trips the draft text exactly', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => stringOf(Math.floor(rng() * 50)),
|
||||||
|
(msg) => {
|
||||||
|
const page = buildPage()
|
||||||
|
page.setInput(msg)
|
||||||
|
return page.input === msg
|
||||||
|
},
|
||||||
|
{ label: 'setInput round-trip' }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('menu link registration is idempotent', async () => {
|
||||||
|
await forAll(
|
||||||
|
(i) => `/posts/${i}`,
|
||||||
|
(path) => {
|
||||||
|
const page = buildPage()
|
||||||
|
page.addMenuLink(path)
|
||||||
|
page.addMenuLink(path)
|
||||||
|
page.addMenuLink(path)
|
||||||
|
return page.menuLinks.filter((p) => p === path).length === 1
|
||||||
|
},
|
||||||
|
{ label: 'menu link idempotence' }
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
Unit tests for the Memo post behavior slice (src/services/memo-post.js).
|
||||||
|
|
||||||
|
These tests express the observable behavior described by
|
||||||
|
specs/post-memo.feature:
|
||||||
|
- a valid memo broadcasts an OP_RETURN transaction carrying the Memo post
|
||||||
|
prefix (0x6d02) and the message text, and the feed reflects the new post.
|
||||||
|
- an empty memo is rejected with a validation error and nothing is broadcast.
|
||||||
|
- an over-long memo is rejected with a length error and nothing is broadcast.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
|
|
||||||
|
// A fake wallet that records every broadcast attempt. It satisfies the small
|
||||||
|
// adapter surface the MemoPost module needs: walletInfo, getUtxos(),
|
||||||
|
// sendOpReturn().
|
||||||
|
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
|
||||||
|
const broadcasts = []
|
||||||
|
const wallet = {
|
||||||
|
walletInfo: { cashAddress },
|
||||||
|
getUtxos: async () => utxos,
|
||||||
|
sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => {
|
||||||
|
broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||||
|
return 'fake-txid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wallet.broadcasts = broadcasts
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fake feed that records posts added to the recent posts feed.
|
||||||
|
function fakeFeed () {
|
||||||
|
const posts = []
|
||||||
|
return { posts, addPost: (p) => posts.push(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
|
||||||
|
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and message', async () => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const feed = fakeFeed()
|
||||||
|
const memoPost = new MemoPost({ wallet, feed })
|
||||||
|
|
||||||
|
const txid = await memoPost.post('hello memo')
|
||||||
|
|
||||||
|
assert.equal(txid, 'fake-txid')
|
||||||
|
assert.equal(wallet.broadcasts.length, 1)
|
||||||
|
const b = wallet.broadcasts[0]
|
||||||
|
assert.equal(b.prefix, '6d02')
|
||||||
|
assert.equal(b.msg, 'hello memo')
|
||||||
|
// The broadcast uses the wallet's spendable UTXOs.
|
||||||
|
assert.equal(b.bchUtxos.length, 1)
|
||||||
|
// The wallet info passed to sendOpReturn is the authenticated wallet.
|
||||||
|
assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress)
|
||||||
|
|
||||||
|
// The feed reflects the new post from this address with this text.
|
||||||
|
assert.equal(feed.posts.length, 1)
|
||||||
|
assert.equal(feed.posts[0].text, 'hello memo')
|
||||||
|
assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting a memo at the maximum length (217) is accepted', async () => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const memoPost = new MemoPost({ wallet })
|
||||||
|
|
||||||
|
const msg = 'x'.repeat(217)
|
||||||
|
const txid = await memoPost.post(msg)
|
||||||
|
assert.equal(txid, 'fake-txid')
|
||||||
|
assert.equal(wallet.broadcasts[0].msg, msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting an empty memo throws a validation error and broadcasts nothing', async () => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const feed = fakeFeed()
|
||||||
|
const memoPost = new MemoPost({ wallet, feed })
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
memoPost.post(''),
|
||||||
|
(err) => err.code === 'memo_validation'
|
||||||
|
)
|
||||||
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
|
assert.equal(feed.posts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => {
|
||||||
|
for (const invalid of [' ', 42]) {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const memoPost = new MemoPost({ wallet })
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
memoPost.post(invalid),
|
||||||
|
(err) => err.code === 'memo_validation'
|
||||||
|
)
|
||||||
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const feed = fakeFeed()
|
||||||
|
const memoPost = new MemoPost({ wallet, feed })
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
memoPost.post('y'.repeat(218)),
|
||||||
|
(err) => err.code === 'memo_length'
|
||||||
|
)
|
||||||
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
|
assert.equal(feed.posts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting without a wallet reports a missing-wallet error', async () => {
|
||||||
|
const memoPost = new MemoPost({})
|
||||||
|
await assert.rejects(
|
||||||
|
memoPost.post('hello memo'),
|
||||||
|
(err) => /wallet/i.test(err.message)
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
Unit tests for the New Post Page behavior slice (src/services/new-post.js).
|
||||||
|
|
||||||
|
Expresses the observable behavior described by specs/memo-new.feature:
|
||||||
|
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||||
|
navigates the user to the recent feed.
|
||||||
|
- an empty memo is rejected with a validation error; nothing is broadcast.
|
||||||
|
- an over-long memo is rejected with a length error; nothing is broadcast.
|
||||||
|
- the character counter counts down from the memo limit.
|
||||||
|
- the navigation menu links to /posts/new.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const test = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const MemoPost = require('../../src/services/memo-post')
|
||||||
|
const NewPostPage = require('../../src/services/new-post')
|
||||||
|
|
||||||
|
const MAX = MemoPost.MAX_MEMO_CHARS // 217
|
||||||
|
|
||||||
|
// A fake wallet recording broadcast attempts.
|
||||||
|
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
|
||||||
|
const broadcasts = []
|
||||||
|
const wallet = {
|
||||||
|
walletInfo: { cashAddress },
|
||||||
|
utxos: [{ txid: 'utxo-fee' }],
|
||||||
|
getUtxos: async function () { return this.utxos },
|
||||||
|
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||||
|
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||||
|
return 'newpost-txid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wallet.broadcasts = broadcasts
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeFeed () {
|
||||||
|
const posts = []
|
||||||
|
return { posts, addPost: (p) => posts.push(p) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function build () {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const feed = fakeFeed()
|
||||||
|
const memoPost = new MemoPost({ wallet, feed })
|
||||||
|
const navigations = []
|
||||||
|
const page = new NewPostPage({
|
||||||
|
memoPost,
|
||||||
|
navigate: (path) => navigations.push(path)
|
||||||
|
})
|
||||||
|
return { wallet, feed, memoPost, page, navigations }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
|
||||||
|
assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new')
|
||||||
|
assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the new post page is linked from the navigation menu', () => {
|
||||||
|
const { page } = build()
|
||||||
|
assert.equal(page.hasMenuLink('/posts/new'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the character counter counts down from the memo limit for an empty memo', () => {
|
||||||
|
const { page } = build()
|
||||||
|
page.setInput('')
|
||||||
|
assert.equal(page.remainingCount(), MAX)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the character counter counts down from the memo limit for a short memo', () => {
|
||||||
|
const { page } = build()
|
||||||
|
page.setInput('hello')
|
||||||
|
assert.equal(page.remainingCount(), MAX - 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the character counter reaches zero at the memo limit', () => {
|
||||||
|
const { page } = build()
|
||||||
|
page.setInput('x'.repeat(MAX))
|
||||||
|
assert.equal(page.remainingCount(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => {
|
||||||
|
const { wallet, feed, page, navigations } = build()
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
// The page returns to an idle (not posting) state after success.
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
// Broadcast happened with the Memo post prefix and the exact message.
|
||||||
|
assert.equal(wallet.broadcasts.length, 1)
|
||||||
|
assert.equal(wallet.broadcasts[0].prefix, '6d02')
|
||||||
|
assert.equal(wallet.broadcasts[0].msg, 'hello memo')
|
||||||
|
// Navigated to the recent feed after posting.
|
||||||
|
assert.deepEqual(navigations, ['/posts/recent'])
|
||||||
|
// The feed reflects the new post from this address.
|
||||||
|
assert.equal(feed.posts.length, 1)
|
||||||
|
assert.equal(feed.posts[0].text, 'hello memo')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => {
|
||||||
|
const { wallet, feed, page, navigations } = build()
|
||||||
|
page.setInput('')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'memo_validation')
|
||||||
|
assert.equal(page.submitError, 'memo_validation')
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
|
assert.equal(feed.posts.length, 0)
|
||||||
|
assert.deepEqual(navigations, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting an over-long memo is rejected with a length error and nothing is broadcast', async () => {
|
||||||
|
const { wallet, feed, page, navigations } = build()
|
||||||
|
page.setInput('y'.repeat(MAX + 1))
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.equal(result.error, 'memo_length')
|
||||||
|
assert.equal(page.submitError, 'memo_length')
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
assert.equal(wallet.broadcasts.length, 0)
|
||||||
|
assert.equal(feed.posts.length, 0)
|
||||||
|
assert.deepEqual(navigations, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the new post page starts idle (not posting)', () => {
|
||||||
|
const { page } = build()
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('posting is true while a submit is in flight and false once it settles', async () => {
|
||||||
|
const wallet = fakeWallet()
|
||||||
|
const feed = fakeFeed()
|
||||||
|
|
||||||
|
// Defer the broadcast so we can observe the in-flight posting state.
|
||||||
|
let resolveSend
|
||||||
|
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||||
|
const page = new NewPostPage({
|
||||||
|
memoPost: new MemoPost({ wallet, feed }),
|
||||||
|
navigate: () => {}
|
||||||
|
})
|
||||||
|
page.setInput('hello memo')
|
||||||
|
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
const pending = page.submit()
|
||||||
|
assert.equal(page.posting, true)
|
||||||
|
|
||||||
|
// Yield until the async chain reaches the deferred sendOpReturn call.
|
||||||
|
await new Promise((r) => setImmediate(r))
|
||||||
|
assert.equal(typeof resolveSend, 'function')
|
||||||
|
resolveSend('in-flight-txid')
|
||||||
|
await pending
|
||||||
|
assert.equal(page.posting, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('submitting without a memo post handler reports an error and does not navigate', async () => {
|
||||||
|
const navigations = []
|
||||||
|
const page = new NewPostPage({ navigate: (p) => navigations.push(p) })
|
||||||
|
page.setInput('hello')
|
||||||
|
|
||||||
|
const result = await page.submit()
|
||||||
|
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.deepEqual(navigations, [])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user