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/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
|
||||
.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": {
|
||||
"start": "react-scripts start",
|
||||
"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",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"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
|
||||
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
|
||||
Feature: Post a Memo
|
||||
|
||||
|
||||
@@ -14,14 +14,7 @@ const WalletImport = (props) => {
|
||||
const { appData } = props
|
||||
|
||||
// Load mnemonic from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const mnemonic = await appData.appUtil.readFromClipboard()
|
||||
setNewMnemonic(mnemonic)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setNewMnemonic)
|
||||
|
||||
// Handle input change for mnemonic
|
||||
const handleImportMnemonic = async (event) => {
|
||||
|
||||
@@ -38,29 +38,14 @@ function WalletSummary (props) {
|
||||
privateKey: blurredPrivateKey ? faEyeSlash : faEye
|
||||
}
|
||||
|
||||
// Toggle the state of blurring for the mnemonic
|
||||
const toggleMnemonicBlur = (inObj = {}) => {
|
||||
// Toggle the state of blurring for a field (mnemonic or private key)
|
||||
const toggleBlur = (field) => {
|
||||
try {
|
||||
const { walletSummaryData } = inObj
|
||||
|
||||
// toggle the state of blurring
|
||||
const blurredState = walletSummaryData.blurredMnemonic
|
||||
walletSummaryData.setBlurredMnemonic(!blurredState)
|
||||
const blurredState = walletSummaryData[field]
|
||||
const setterName = `set${field[0].toUpperCase()}${field.slice(1)}`
|
||||
walletSummaryData[setterName](!blurredState)
|
||||
} catch (error) {
|
||||
console.error('Error toggling mnemonic 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)
|
||||
console.error(`Error toggling ${field} blur: `, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +72,7 @@ function WalletSummary (props) {
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.mnemonic}
|
||||
size='lg'
|
||||
onClick={() => toggleMnemonicBlur({ walletSummaryData })}
|
||||
onClick={() => toggleBlur('blurredMnemonic')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
|
||||
@@ -104,7 +89,7 @@ function WalletSummary (props) {
|
||||
style={{ cursor: 'pointer' }}
|
||||
icon={eyeIcon.privateKey}
|
||||
size='lg'
|
||||
onClick={() => togglePrivateKeyBlur({ walletSummaryData })}
|
||||
onClick={() => toggleBlur('blurredPrivateKey')}
|
||||
/>
|
||||
</Col>
|
||||
<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 RecentProfiles from './recent-profiles'
|
||||
import RecentPosts from './posts'
|
||||
import NewPost from './new-post'
|
||||
import Profile from './profile'
|
||||
|
||||
function AppBody (props) {
|
||||
@@ -42,6 +43,7 @@ function AppBody (props) {
|
||||
<Route path='/profile/recent' element={<RecentProfiles />} />
|
||||
<Route path='/profile/:addr' element={<Profile />} />
|
||||
<Route path='/posts/recent' element={<RecentPosts />} />
|
||||
<Route path='/posts/new' element={<NewPost appData={appData} />} />
|
||||
<Route path='/placeholder2' element={<Placeholder2 />} />
|
||||
<Route path='/placeholder3' element={<Placeholder3 />} />
|
||||
<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
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
// Local libraries
|
||||
import PlaceholderView from './placeholder-view'
|
||||
|
||||
function Placeholder2 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 2 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
|
||||
</>
|
||||
)
|
||||
return <PlaceholderView viewNumber={2} />
|
||||
}
|
||||
|
||||
export default Placeholder2
|
||||
|
||||
@@ -2,19 +2,11 @@
|
||||
This is a placeholder View
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useEffect } from 'react'
|
||||
// Local libraries
|
||||
import PlaceholderView from './placeholder-view'
|
||||
|
||||
function Placeholder3 (props) {
|
||||
useEffect(() => {
|
||||
console.log('Placeholder 3 loaded.')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
|
||||
</>
|
||||
)
|
||||
return <PlaceholderView viewNumber={3} />
|
||||
}
|
||||
|
||||
export default Placeholder3
|
||||
|
||||
@@ -8,17 +8,11 @@ import { Container, Row, Col, Spinner, Table } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import AppUtil from '../../../util'
|
||||
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
|
||||
import '../../../App.css'
|
||||
|
||||
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) {
|
||||
if (!seen) return ''
|
||||
const ms = seen > 1e12 ? seen : seen * 1000
|
||||
@@ -88,7 +82,7 @@ function RecentProfiles () {
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
title={profile.addr}
|
||||
>
|
||||
{truncate(profile.addr, 24)}
|
||||
{truncateAddr(profile.addr, 24)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{profile.text}</td>
|
||||
@@ -100,7 +94,7 @@ function RecentProfiles () {
|
||||
title={profile.txid}
|
||||
onClick={() => appUtil.copyToClipboard(profile.txid)}
|
||||
>
|
||||
{truncate(profile.txid, 20)}
|
||||
{truncateTxid(profile.txid, 20)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -105,14 +105,7 @@ function SendTokenButton ({ token, appData, refreshTokens }) {
|
||||
}
|
||||
|
||||
// Load address from clipboard
|
||||
const pasteFromClipboard = async () => {
|
||||
try {
|
||||
const address = await appData.appUtil.readFromClipboard()
|
||||
setSendToAddress(address)
|
||||
} catch (err) {
|
||||
console.warn('Error pasting from clipboard: ', err)
|
||||
}
|
||||
}
|
||||
const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setSendToAddress)
|
||||
|
||||
// Modal JSX
|
||||
const getModal = () => {
|
||||
|
||||
@@ -70,6 +70,14 @@ function NavMenu (props) {
|
||||
Posts
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/posts/new'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
New Post
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/wallet'
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Shared display helpers for post feed and thread views.
|
||||
*/
|
||||
|
||||
import { truncateAddr } from '../../util'
|
||||
|
||||
export { truncateAddr, truncateTxid } from '../../util'
|
||||
|
||||
export function formatRelativeSeen (seen) {
|
||||
if (!seen) return ''
|
||||
const ms = seen > 1e12 ? seen : seen * 1000
|
||||
@@ -26,16 +30,6 @@ export function formatRelativeSeen (seen) {
|
||||
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) {
|
||||
const profile = profiles?.[addr]
|
||||
if (profile?.name) {
|
||||
|
||||
+25
-46
@@ -11,72 +11,51 @@ class MemoDb {
|
||||
}
|
||||
|
||||
async getRecentProfiles ({ limit = 100, offset = 0 } = {}) {
|
||||
try {
|
||||
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
|
||||
}
|
||||
return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset })
|
||||
}
|
||||
|
||||
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 {
|
||||
const result = await this.axios.get(`${config.backend}/posts/recent`, {
|
||||
params: { limit, offset }
|
||||
const result = await this.axios.get(`${config.backend}${path}`, {
|
||||
params
|
||||
})
|
||||
|
||||
return result.data
|
||||
} catch (err) {
|
||||
console.error('Error in getRecentPosts()')
|
||||
console.error(`Error in ${name}()`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile (addr) {
|
||||
// GET a level endpoint that resolves an address. Returns null on 404.
|
||||
async getLevelResource (endpoint, addr, name) {
|
||||
try {
|
||||
const result = await this.axios.get(
|
||||
`${config.backend}/level/profile/${encodeURIComponent(addr)}`
|
||||
`${config.backend}/level/${endpoint}/${encodeURIComponent(addr)}`
|
||||
)
|
||||
return result.data
|
||||
} catch (err) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
return null
|
||||
}
|
||||
console.error('Error in getProfile()')
|
||||
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()')
|
||||
console.error(`Error in ${name}()`)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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