mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge pull request #5 from Permissionless-Software-Foundation/display-name
Display name & Reply prototype
This commit is contained in:
@@ -2,5 +2,6 @@ node_modules/
|
||||
build/
|
||||
docs/
|
||||
tmp/
|
||||
target/
|
||||
|
||||
.gitsigners
|
||||
|
||||
+324
-7
@@ -2,24 +2,34 @@
|
||||
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.
|
||||
(src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js,
|
||||
src/services/reply-thread-page.js, src/services/memo-set-name.js, and
|
||||
src/services/set-name-page.js), driving them through small injected adapters
|
||||
(a fake wallet, a fake feed, a fake thread, 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.
|
||||
The handlers serve specs/post-memo.feature, specs/memo-new.feature,
|
||||
specs/reply-memo.feature, and specs/set-name.feature, whose wording differs
|
||||
but which share the same underlying Memo action/page-controller behavior.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
const ReplyThreadPage = require('../../src/services/reply-thread-page')
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const SetNamePage = require('../../src/services/set-name-page')
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
||||
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
|
||||
|
||||
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||
function makeWallet (address) {
|
||||
@@ -30,9 +40,9 @@ function makeWallet (address) {
|
||||
getUtxos: async function () {
|
||||
return this.utxos
|
||||
},
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
// Record the broadcast attempt, then fail if configured to do so.
|
||||
this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix })
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
@@ -49,15 +59,39 @@ function makeFeed () {
|
||||
}
|
||||
}
|
||||
|
||||
// A fake profile store recording display names set for addresses.
|
||||
function makeProfiles () {
|
||||
const names = {}
|
||||
return {
|
||||
names,
|
||||
setName: (addr, name) => { names[addr] = name },
|
||||
getName: (addr) => names[addr] || null
|
||||
}
|
||||
}
|
||||
|
||||
// A fake thread store recording replies added to a post thread.
|
||||
function makeThread () {
|
||||
const replies = []
|
||||
return {
|
||||
rootTxid: null,
|
||||
replies,
|
||||
addReply: (r) => replies.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh world/state object for a single scenario execution.
|
||||
function createWorld () {
|
||||
const wallet = makeWallet('')
|
||||
const feed = makeFeed()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
const thread = makeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const world = {
|
||||
wallet,
|
||||
feed,
|
||||
thread,
|
||||
memoPost,
|
||||
memoReply,
|
||||
currentPath: null,
|
||||
menuOpen: false
|
||||
}
|
||||
@@ -70,9 +104,38 @@ function createWorld () {
|
||||
menuLinks: []
|
||||
})
|
||||
|
||||
// The Reply Thread Page controller wraps the memo reply behavior. It does
|
||||
// not navigate on success so the user stays in the thread modal.
|
||||
world.replyPage = new ReplyThreadPage({
|
||||
memoReply,
|
||||
navigate: () => {}
|
||||
})
|
||||
|
||||
// The Set Name Page and Account Page controllers share a profile store so
|
||||
// a name set on one page is visible on the other.
|
||||
const profiles = makeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
world.setNamePage = new SetNamePage({
|
||||
memoSetName,
|
||||
navigate: (path) => { world.currentPath = path }
|
||||
})
|
||||
world.accountPage = new AccountPage({
|
||||
wallet,
|
||||
profiles,
|
||||
navigate: (path) => { world.currentPath = path }
|
||||
})
|
||||
|
||||
return world
|
||||
}
|
||||
|
||||
// Decode a raw reply payload into its parent txid (hex) and reply text.
|
||||
function decodeReplyPayload (raw) {
|
||||
const buf = Buffer.from(raw)
|
||||
const parentTxid = buf.slice(0, 32).toString('hex')
|
||||
const text = buf.slice(32).toString('utf8')
|
||||
return { parentTxid, text }
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
@@ -160,6 +223,17 @@ const handlers = [
|
||||
world.newPage.setInput(example[param])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'type name text',
|
||||
pattern: /^I type a name 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.setNamePage.setInput(example[param])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit/click post',
|
||||
pattern: /^I (?:submit the memo|click the post button)$/,
|
||||
@@ -167,6 +241,123 @@ const handlers = [
|
||||
await world.newPage.submit()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit name',
|
||||
pattern: /^I submit the name$/,
|
||||
async run (m, example, world) {
|
||||
await world.setNamePage.submit()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread modal shows reply form',
|
||||
pattern: /^the thread modal shows a reply form$/,
|
||||
run (m, example, world) {
|
||||
// The reply form is always considered visible once the thread is open.
|
||||
if (!world.replyPage) {
|
||||
throw new Error('No reply page is attached to the thread.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post with txid has no replies',
|
||||
pattern: /^a post with the txid (.+) has no replies$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
world.thread.rootTxid = txid
|
||||
world.replyPage.setParent(txid)
|
||||
// A fresh thread store already has no replies.
|
||||
if (world.thread.replies.length !== 0) {
|
||||
throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click comment icon on post',
|
||||
pattern: /^I click the comment icon on the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
// Opening the thread modal means setting the active thread txid.
|
||||
world.thread.rootTxid = txid
|
||||
world.replyPage.setParent(txid)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread modal opens for post',
|
||||
pattern: /^the thread modal opens for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
if (world.thread.rootTxid !== txid) {
|
||||
throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`)
|
||||
}
|
||||
if (!world.replyPage) {
|
||||
throw new Error('Thread modal opened without a reply form page.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open reply thread',
|
||||
pattern: /^I open the thread for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
world.thread.rootTxid = txid
|
||||
world.replyPage.setParent(txid)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'type reply text',
|
||||
pattern: /^I type a reply 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.replyPage.setInput(example[param])
|
||||
world.replyPage.setParent(world.thread.rootTxid)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'type reply to nested reply',
|
||||
pattern: /^I type a reply to the nested reply 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.replyPage.setInput(example[param])
|
||||
if (!world.nestedTxid) {
|
||||
throw new Error('No nested reply has been selected.')
|
||||
}
|
||||
world.replyPage.setParent(world.nestedTxid)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit reply',
|
||||
pattern: /^I submit the reply$/,
|
||||
async run (m, example, world) {
|
||||
await world.replyPage.submit()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread shows nested reply',
|
||||
pattern: /^the thread shows a nested reply with the txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
world.nestedTxid = txid
|
||||
world.thread.addReply({
|
||||
txid,
|
||||
address: 'someone-else',
|
||||
text: 'nested reply',
|
||||
parentTxid: world.thread.rootTxid
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Set Name button',
|
||||
pattern: /^I click the Set Name button$/,
|
||||
run (m, example, world) {
|
||||
world.accountPage.clickSetName()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts/attempts OP_RETURN with Memo post prefix',
|
||||
pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/,
|
||||
@@ -184,6 +375,85 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo set-name prefix',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name 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_SET_NAME_PREFIX) {
|
||||
throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg !== world.setNamePage.input) {
|
||||
throw new Error('Broadcast name text did not match the typed name.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo reply prefix',
|
||||
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply 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_REPLY_PREFIX) {
|
||||
throw new Error(`Expected Memo reply prefix ${MEMO_REPLY_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
const { parentTxid, text } = decodeReplyPayload(last.msg)
|
||||
if (parentTxid !== world.replyPage.parentTxid) {
|
||||
throw new Error('Broadcast parent txid did not match the expected reply target.')
|
||||
}
|
||||
if (text !== world.replyPage.input) {
|
||||
throw new Error('Broadcast reply text did not match the typed reply.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread shows new reply from my address',
|
||||
pattern: /^the thread shows a new reply 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.thread.replies.find(
|
||||
(r) => r.text === expectedText && r.address === myAddress
|
||||
)
|
||||
if (!found) {
|
||||
throw new Error(`Thread does not show the new reply with text "${expectedText}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread shows validation/length error',
|
||||
pattern: /^the thread shows a (validation|length) error$/,
|
||||
run (m, example, world) {
|
||||
const kind = m[1]
|
||||
const expectedCode = kind === 'validation' ? 'reply_validation' : 'reply_length'
|
||||
if (world.replyPage.submitError !== expectedCode) {
|
||||
throw new Error(`Expected ${expectedCode}, got ${world.replyPage.submitError}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread remaining byte count',
|
||||
pattern: /^the thread shows a remaining byte 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.replyPage.remainingCount()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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_]+)>"$/,
|
||||
@@ -222,6 +492,17 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set name page shows validation/length error',
|
||||
pattern: /^the set name page shows a (validation|length) error$/,
|
||||
run (m, example, world) {
|
||||
const kind = m[1]
|
||||
const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length'
|
||||
if (world.setNamePage.submitError !== expectedCode) {
|
||||
throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remaining character count',
|
||||
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
|
||||
@@ -237,6 +518,21 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remaining byte count',
|
||||
pattern: /^the set name page shows a remaining byte 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.setNamePage.remainingCount()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'app does not broadcast any transaction',
|
||||
pattern: /^(?:the wallet|the app) does not broadcast any transaction$/,
|
||||
@@ -245,6 +541,27 @@ const handlers = [
|
||||
throw new Error('A transaction was broadcast when none was expected.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'account page shows name',
|
||||
pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/,
|
||||
run (m, example, world) {
|
||||
const param = m[1]
|
||||
const expected = example[param]
|
||||
const actual = world.accountPage.getName()
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected account name "${expected}", got "${actual}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'account page shows Set Name button',
|
||||
pattern: /^the account page shows a Set Name button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.accountPage.hasSetNameButton()) {
|
||||
throw new Error('Account page does not show a Set Name button.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Architectural Review Summary — reduce-test-dry-duplication
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `reduce-test-dry-duplication`
|
||||
- Reviewed the refactorer branch ending at `0159119921bc` (fast-forward merged into
|
||||
`swarmforge-architect`), which extracted shared test helpers for the Memo post and
|
||||
Set Name behavior slices. No source files changed; the diff is test-only
|
||||
(427 insertions / 522 deletions across 13 test files).
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality. The refactorer's work is clean; no structural fixes were needed.
|
||||
|
||||
1. **Test helpers separated from tests (good).** Shared fakes and test registrars
|
||||
live in `test/helpers/` (`fake-wallet.js`, `fake-profiles.js`) and
|
||||
`test/unit/*-helpers.js` / `test/property/behavior-helpers.js`, kept apart from
|
||||
the `.test.js` files. The `node --test "test/unit/*.test.js"` and
|
||||
`node --test "test/property/*.test.js"` globs match only `.test.js`, so the
|
||||
helper modules are never executed as standalone tests. This satisfies the
|
||||
"keep tests separate from test helpers" rule.
|
||||
2. **Dependency direction (good).** Helpers depend inward on `src/services` and on
|
||||
each other; no test helper reaches into UI/IO. The fake wallet exposes only the
|
||||
small adapter surface the Memo action modules need (`walletInfo`, `getUtxos`,
|
||||
`sendOpReturn`) and records broadcasts for assertion, preserving information
|
||||
hiding.
|
||||
3. **Cohesion (good).** Each helper owns one concern: `memo-action-helpers.js`
|
||||
registers the shared MemoAction max/over-length/validation tests;
|
||||
`page-controller-helpers.js` registers the shared in-flight/missing-handler/
|
||||
broadcast-failure page tests; `page-build-helpers.js` wires a page to a working
|
||||
action; `behavior-helpers.js` registers the shared property invariants
|
||||
(validation, counter conservation, setInput round-trip, broadcast-failure
|
||||
never navigates). Slice specifics are supplied through a small `cfg` object,
|
||||
so the two behavior slices share one implementation instead of duplicating it.
|
||||
4. **Local code quality (good).** Helper APIs are documented, names are clear, and
|
||||
the `cfg`-driven registrars keep the per-slice test files terse and readable.
|
||||
`.gitignore` now excludes `target/` (project-local tool output).
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 56/56 pass.
|
||||
- **Property (`node --test test/property/*.test.js`):** 13/13 pass.
|
||||
- **Acceptance (normal):** `memo-new`, `post-memo`, and `set-name` generated suites
|
||||
all pass (14 + 5 + 13 scenarios).
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`):** differential run reports
|
||||
0/0/0 (manifests current; source unchanged). `--mutate-all` confirms every
|
||||
testable core module fully kills: memo-action 5/0/0, memo-post 2/0/0,
|
||||
memo-set-name 2/0/0, page-controller 7/0/0, set-name-page 3/0/0, account-page
|
||||
7/0/0, profiles 1/0/0, new-post 4/0/0. No survivors, no uncovered.
|
||||
- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates
|
||||
in either tree.
|
||||
- **Gherkin acceptance mutation (soft):**
|
||||
- `memo-new.feature` — 14 executed, **4 killed, 10 survived**, 0 errors.
|
||||
- `post-memo.feature` — 5 executed, **0 killed, 5 survived**, 0 errors.
|
||||
- `set-name.feature` — 13 executed, **6 killed, 7 survived**, 0 errors.
|
||||
- Killed: byte/char `count` dithers and the empty-value boundary — values are
|
||||
behaviorally connected to the counter and rejection branches.
|
||||
- Survived (documented equivalents): message/name/broadcast-error text dithers
|
||||
are opaque data — any non-empty value broadcasts and reflects identically, so
|
||||
the mutation does not change observable behavior.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills all
|
||||
testable core modules. Gherkin acceptance mutation survivors are documented
|
||||
equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- None. The refactorer's work is test-only (no product behavior change) and this
|
||||
review produced no source changes, so there is no functional commit for the
|
||||
specifier and no follow-up work for the coder/refactorer to review. Per the
|
||||
handoff rules, non-functional work is not forwarded.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Architectural Review Summary — reply-memo
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `reply-memo`
|
||||
- Reviewed the merged branch ending at `b4508e8909` (refactorer), which carried:
|
||||
- `8294b4d` — specifier Reply to a Memo Gherkin spec (`specs/reply-memo.feature`)
|
||||
- `230618d` — specifier browser fix: replace Node-only `Buffer.byteLength` with a
|
||||
TextEncoder-based UTF-8 byte helper (`src/services/utf8.js`) so the Set Name
|
||||
byte counter works in the browser
|
||||
- `d33fda8` — coder implementation (`MemoReply`, `ReplyThreadPage`, `utf8`,
|
||||
acceptance handlers)
|
||||
- `b4508e8` — refactorer extraction of reply tests into the shared helpers
|
||||
- Merged into `swarmforge-architect` (merge commit `c0e4eba`) and processed as a batch.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality.
|
||||
|
||||
1. **UI/Core separation (good).** All reply behavior lives in testable services
|
||||
(`memo-reply.js`, `reply-thread-page.js`) free of UI/IO; the wallet and thread
|
||||
are injected behind small adapter boundaries. `utf8.js` is a shared, browser-safe
|
||||
byte-length helper that fixes a real browser bug (Node `Buffer` is unavailable in
|
||||
the browser) and is reused by both the Set Name and Reply slices.
|
||||
2. **Dependency rule (good).** `memo-reply` depends inward on `memo-action` and
|
||||
`utf8`; `reply-thread-page` depends inward on `page-controller`, `memo-reply`, and
|
||||
`utf8`. No low-level module reaches toward IO.
|
||||
3. **Information hiding (good).** `MemoReply` extends `MemoAction` and supplies the
|
||||
reply-specific `config`, `isTooLong`, and `reflect`; it overrides `reply()` to
|
||||
build the raw wire payload (32-byte parent txid + UTF-8 text) because the reply
|
||||
wire format differs from the plain-value broadcast. `ReplyThreadPage` extends
|
||||
`PageController` and supplies `successPath`, `validationCodes`, `_setBusy`, and
|
||||
`_perform`, plus a `setParent` for nested replies. The `hexToBytes`/`buildReplyPayload`
|
||||
helpers are module-private, keeping the wire format hidden.
|
||||
4. **Test refactoring (good).** The refactorer extended `memo-action-helpers` (extra
|
||||
`extraArgs` for the parent txid, `byteBased` multi-byte tests, `assertBroadcastMsg`)
|
||||
and added `registerPageSubmitTests` to `page-controller-helpers`, so the reply
|
||||
tests reuse the shared registrars instead of duplicating them. Helpers stay
|
||||
separate from `.test.js` files.
|
||||
5. **Fix applied — mutation survivors (2).** The language mutation tool flagged two
|
||||
`|| -> &&` survivors that were equivalent only because of test gaps:
|
||||
- `memo-reply` `hexToBytes`: the 64-character length check was unobservable because
|
||||
the only invalid-txid test used a non-hex string that the hex-parse loop also
|
||||
rejected. Added a test that a wrong-length but valid-hex txid is rejected with
|
||||
the length error, killing the mutation.
|
||||
- `reply-thread-page` constructor `successPath`: the page was never constructed
|
||||
with a `successPath`, so the `|| -> &&` wiring was unobservable. Added a test
|
||||
that a configured success path is honored (navigates on success), killing the
|
||||
mutation.
|
||||
Both tests are behavior-preserving and close real coverage gaps.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 86/86 pass (was 84; +2 survivor-killing tests).
|
||||
- **Property (`node --test test/property/*.test.js`):** 13/13 pass.
|
||||
- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, and `reply-memo`
|
||||
generated suites all pass (4 suites).
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):**
|
||||
- memo-action 5/0/0, memo-post 2/0/0, memo-set-name 2/0/0, memo-reply 8/0/0,
|
||||
page-controller 7/0/0, new-post 4/0/0, set-name-page 3/0/0, reply-thread-page
|
||||
5/0/0, account-page 7/0/0, profiles 1/0/0, utf8 0/0/0 (no mutation sites).
|
||||
- All testable core modules fully kill; no survivors, no uncovered. The two
|
||||
`|| -> &&` survivors were killed by the added tests.
|
||||
- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates
|
||||
in either tree.
|
||||
- **Gherkin acceptance mutation (soft):**
|
||||
- `reply-memo.feature` — 13 executed, **5 killed, 8 survived**, 0 errors.
|
||||
- `memo-new.feature` — 14 executed, **4 killed, 10 survived**, 0 errors.
|
||||
- `post-memo.feature` — 5 executed, **0 killed, 5 survived**, 0 errors.
|
||||
- `set-name.feature` — 13 executed, **6 killed, 7 survived**, 0 errors.
|
||||
- Killed: byte/char `count` dithers and the empty-value boundary — values are
|
||||
behaviorally connected to the counter and rejection branches.
|
||||
- Survived (documented equivalents): message/name/broadcast-error text dithers are
|
||||
opaque data — any non-empty value broadcasts and reflects identically, so the
|
||||
mutation does not change observable behavior.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills all
|
||||
testable core modules. Gherkin acceptance mutation survivors are documented
|
||||
equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `reply-memo`), to review the
|
||||
architect commit (survivor-killing test additions + refreshed tool manifests).
|
||||
- No handoff to the specifier: the architect produced no functional feature commit
|
||||
(the reply-memo feature was implemented by the coder and already spec-approved).
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Architectural Review Summary — reply-thread-fix
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `reply-thread-fix`
|
||||
- Reviewed the refactorer branch ending at `de24180c4d` (fast-forward merged into
|
||||
`swarmforge-architect`), which carried:
|
||||
- `ab84389` — specifier: reworded `reply-memo.feature` so each scenario sets up its
|
||||
own thread-open step and added scenario 6 (comment icon opens thread with zero
|
||||
replies) and scenario 7 (thread modal shows a reply form)
|
||||
- `6e8dcaf` — coder: wired `ReplyThreadForm` into `PostThreadModal`, made the
|
||||
`PostReplyCount` comment icon always clickable (so zero-reply posts open the
|
||||
thread), passed wallet/profiles through the component tree, and added acceptance
|
||||
handlers for the new scenarios
|
||||
- `de24180` — refactorer: extracted the optimistic reply object construction out of
|
||||
`ReplyThreadForm` into a pure, testable service module
|
||||
(`src/services/optimistic-reply.js`) with unit tests
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and
|
||||
local code quality.
|
||||
|
||||
1. **UI/Core separation (good, and the heart of the refactorer's change).** The
|
||||
optimistic-reply object shaping previously lived inline inside the React
|
||||
`ReplyThreadForm` component, where it was untestable. The refactorer extracted it
|
||||
into `src/services/optimistic-reply.js` — a pure `buildOptimisticReply({...})`
|
||||
data-shaping function — with a focused unit test (`test/unit/optimistic-reply.test.js`).
|
||||
The component now stays a thin UI adapter that calls the tested builder.
|
||||
2. **Dependency rule (good).** `optimistic-reply` depends on nothing; the UI component
|
||||
depends inward on the service. No low-level module reaches toward IO.
|
||||
3. **Information hiding (good).** The reply object shape is a single, documented
|
||||
function; the React form exposes only the user-facing form surface and delegates
|
||||
submission to the already-tested `MemoReply`/`ReplyThreadPage` controllers.
|
||||
4. **Coder wiring (good).** `PostReplyCount` is now clickable whenever an `onClick`
|
||||
handler is provided, so zero-reply posts can open the thread (matching scenario 6).
|
||||
`PostThreadModal` holds optimistic replies in local state, cleared on hide/txid
|
||||
change, and renders them through `PostThreadNode`.
|
||||
5. **Local code quality (good).** Small, clear modules; the extracted builder has one
|
||||
responsibility. No duplication introduced.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 89/89 pass (optimistic-reply included).
|
||||
- **Property (`node --test test/property/*.test.js`):** 13/13 pass.
|
||||
- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, and `reply-memo`
|
||||
(7 scenarios) generated suites all pass.
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):**
|
||||
- optimistic-reply 1/0/0, memo-action 5/0/0, memo-post 2/0/0, memo-set-name 2/0/0,
|
||||
memo-reply 8/0/0, page-controller 7/0/0, new-post 4/0/0, set-name-page 3/0/0,
|
||||
reply-thread-page 5/0/0, account-page 7/0/0, profiles 1/0/0.
|
||||
- All testable core modules fully kill; no survivors, no uncovered.
|
||||
- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates
|
||||
in either tree.
|
||||
- **Gherkin acceptance mutation (soft):** `reply-memo.feature` — 13 executed,
|
||||
**5 killed, 8 survived**, 0 errors.
|
||||
- Killed: byte `count` dithers and the empty-value boundary — behaviorally connected.
|
||||
- Survived (documented equivalents): message text dithers are opaque data — any
|
||||
non-empty value broadcasts and reflects identically.
|
||||
- Scenarios 6/7 (plain `Scenario`s) carry no example mutations.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills all
|
||||
testable core modules. Gherkin acceptance mutation survivors are documented
|
||||
equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `reply-thread-fix`), to
|
||||
review the architect commit (manifest refreshes only — no source changes).
|
||||
- No handoff to the specifier: the architect produced no functional feature commit.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Architectural Review Summary — set-name
|
||||
|
||||
## Task and commits reviewed
|
||||
- Task: `set-name`
|
||||
- Reviewed the merged branch ending at `d79b9f04bd` (refactorer), which carried:
|
||||
- `bb9d00a` — specifier Set Name Gherkin spec (`specs/set-name.feature`)
|
||||
- `bd2eac5` — coder implementation (MemoSetName, SetNamePage, AccountPage,
|
||||
Profiles store, React views, acceptance handlers)
|
||||
- `d79b9f0` — refactorer extraction of `MemoAction` and `PageController` base
|
||||
classes plus set-name property tests
|
||||
- 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. **Base-class extraction (good).** `MemoAction` (shared by `MemoPost` and
|
||||
`MemoSetName`) owns `validate`/`broadcast`/`_throwIfInvalid`; subclasses
|
||||
supply only the protocol-specific `isTooLong`/`reflect` and their config.
|
||||
`PageController` (shared by `NewPostPage` and `SetNamePage`) owns
|
||||
`setInput`/`submit`/`_handleSubmitFailure`; subclasses supply
|
||||
`successPath`, `validationCodes`, `_setBusy`, and `_perform`. This removes
|
||||
structural duplication between the two memo actions and the two page
|
||||
controllers while keeping dependency direction inward.
|
||||
2. **UI/Core separation (good).** All behavior lives in testable services free
|
||||
of UI/IO; the React views (`set-name`, `account`, `app-body`, `nav-menu`)
|
||||
are thin shells that inject wallet/profiles/navigate adapters. The shared
|
||||
`Profiles` session store keeps the Set Name and Account pages in sync
|
||||
without leaking persistence structures across the boundary.
|
||||
3. **Acceptance handlers (good).** `acceptance/lib/handlers.js` uses regex
|
||||
parameter capture as the default style and shares one `world` across the
|
||||
memo-post, new-post, set-name, and account features; the fake wallet
|
||||
`sendOpReturn` signature was corrected to the public minimal-slp-wallet API.
|
||||
4. **Fix applied — constructor duplication (DRY).** The language DRY tool
|
||||
flagged a score=1.00 duplicate between the `MemoPost` and `MemoSetName`
|
||||
constructors (identical config-assignment shape). Moved the per-action
|
||||
config into a static `config` object on each subclass and had the base
|
||||
`MemoAction` constructor read `this.constructor.config`. This eliminates
|
||||
the duplicate constructor pattern while keeping the config values
|
||||
subclass-specific and readable. DRY now reports no duplicate candidates.
|
||||
|
||||
## Verification results
|
||||
- **Unit (`node --test`):** 56/56 pass.
|
||||
- **Property (`npm run test:property`):** 13/13 pass (set-name validation
|
||||
classification, byte-counter conservation, setInput round-trip, broadcast
|
||||
failure never navigates).
|
||||
- **Acceptance (normal):** `memo-new`, `post-memo`, and `set-name` generated
|
||||
suites all pass (11 + 6 + 11 scenarios).
|
||||
- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):**
|
||||
- `memo-action.js` — killed 5 / survived 0 / uncovered 0
|
||||
- `memo-post.js` — killed 2 / survived 0 / uncovered 0
|
||||
- `memo-set-name.js` — killed 2 / survived 0 / uncovered 0
|
||||
- `page-controller.js` — killed 7 / survived 0 / uncovered 0
|
||||
- `set-name-page.js` — killed 3 / survived 0 / uncovered 0
|
||||
- `account-page.js` — killed 7 / survived 0 / uncovered 0
|
||||
- `profiles.js` — killed 1 / survived 0 / uncovered 0
|
||||
- `new-post.js` — killed 0 / survived 0 / uncovered 0 (manifest current)
|
||||
- Manifests refreshed for all refactored/new files.
|
||||
- **DRY (`dry4javascript src`):** no duplicate candidates (constructor
|
||||
duplication removed).
|
||||
- **Gherkin acceptance mutation (soft):** `set-name.feature` — 13 executed,
|
||||
**6 killed, 7 survived**, 0 errors (1 scenario/1 mutation reused from the
|
||||
clean empty-name scenario).
|
||||
- Killed: byte-counter `count` values and the empty-name boundary — values
|
||||
are behaviorally connected.
|
||||
- Survived (documented equivalents): name-text dithers (m1, m2, m10) are
|
||||
opaque data — any non-empty name broadcasts and reflects identically; and
|
||||
over-length name dithers (m4, m5, m6, m14) remain over-length, so the
|
||||
length-rejection and zero-count branches are unchanged.
|
||||
- Property tests run separately via `npm run test:property`.
|
||||
|
||||
## Suite status
|
||||
- Unit + property + acceptance all pass; source-level mutation fully kills all
|
||||
testable core modules. Gherkin acceptance mutation survivors are documented
|
||||
equivalents.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` → coder, refactorer (priority `00`, task `set-name`), to review
|
||||
the architect commit (constructor DRY fix + refreshed tool manifests).
|
||||
|
||||
By architect.
|
||||
Generated
+50
@@ -29,8 +29,11 @@
|
||||
"use-query-params": "1.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"crap4javascript": "github:FullStack-Agents/crap4javascript",
|
||||
"dry4javascript": "github:FullStack-Agents/dry4javascript",
|
||||
"husky": "9.1.7",
|
||||
"minimal-slp-wallet": "5.13.1",
|
||||
"mutate4javascript": "github:FullStack-Agents/mutate4javascript",
|
||||
"semantic-release": "24.2.3",
|
||||
"standard": "17.0.0",
|
||||
"web3.storage": "4.3.0"
|
||||
@@ -7566,6 +7569,22 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/crap4javascript": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+ssh://git@github.com/FullStack-Agents/crap4javascript.git#32a11e784c5ccc4c8d8ada8659826c0641269479",
|
||||
"dev": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.0",
|
||||
"@babel/traverse": "^7.26.0"
|
||||
},
|
||||
"bin": {
|
||||
"crap4javascript": "bin/crap4javascript.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/create-hash": {
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
@@ -8451,6 +8470,21 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/dry4javascript": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+ssh://git@github.com/FullStack-Agents/dry4javascript.git#8ba1585b1c817e3492e94a616bda5237afcae73c",
|
||||
"dev": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.0"
|
||||
},
|
||||
"bin": {
|
||||
"dry4javascript": "bin/dry4javascript.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"license": "MIT",
|
||||
@@ -16341,6 +16375,22 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mutate4javascript": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+ssh://git@github.com/FullStack-Agents/mutate4javascript.git#553998e78e31ade25d13814d66cbcabc6749c50c",
|
||||
"dev": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.26.0",
|
||||
"@babel/traverse": "^7.26.0"
|
||||
},
|
||||
"bin": {
|
||||
"mutate4javascript": "bin/mutate4javascript.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -49,8 +49,11 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"crap4javascript": "github:FullStack-Agents/crap4javascript",
|
||||
"dry4javascript": "github:FullStack-Agents/dry4javascript",
|
||||
"husky": "9.1.7",
|
||||
"minimal-slp-wallet": "5.13.1",
|
||||
"mutate4javascript": "github:FullStack-Agents/mutate4javascript",
|
||||
"semantic-release": "24.2.3",
|
||||
"standard": "17.0.0",
|
||||
"web3.storage": "4.3.0"
|
||||
|
||||
+33
-7
@@ -104,12 +104,22 @@ Saved (and updated) at `specs/feature-backlog.md`. memo protocol action bytes be
|
||||
|
||||
**Completed ✓ (merged to `feat1`):**
|
||||
- Post a Memo (`0x6d02`) — service + `/posts/new` page + broadcast fix. FULLY DONE.
|
||||
- Set display name (`0x6d01`) — `/account` + `/memo/set-name` pages, byte counter (77 bytes). DONE.
|
||||
|
||||
**Tier P1 — Core social verbs (write + read) — do these next, in order:**
|
||||
1. ✅ Post a Memo (`0x6d02`) — DONE
|
||||
2. Set display name (`0x6d01`) — **NEXT** (breadcrumb: the feed already renders
|
||||
display names; the write action is missing)
|
||||
3. Reply to a Memo (`0x6d03`) — thread already renders; add reply broadcast
|
||||
2. ✅ Set display name (`0x6d01`) — DONE
|
||||
3. ✅ Reply to a Memo (`0x6d03`) — DONE (merged to `display-name` @ `93e96e7`)
|
||||
- **User-approved decisions (2026-08-26, from memo.cash UI review):**
|
||||
- Reply max = **184 bytes** (UTF-8 byte count, memo.cash `MaxSize.Reply`).
|
||||
- Reply form lives **inside the thread modal** (not inline in the feed).
|
||||
- Keep the existing comment-icon behavior (opens the thread modal); put the reply
|
||||
form in the modal.
|
||||
- Replicate the live `[remaining]` byte counter (turns red when over limit).
|
||||
- Update the thread **optimistically** after broadcast (no refresh).
|
||||
- Users can **reply to a reply** (nested), not just the root post.
|
||||
- Implemented as `src/services/memo-reply.js` (prefix `6d03`) + `reply-thread-page.js`;
|
||||
spec `specs/reply-memo.feature`; all unit + acceptance tests pass; build OK.
|
||||
4. Like / tip a Memo (`0x6d04`)
|
||||
5. Set profile text / bio (`0x6d05`)
|
||||
6. Set profile picture (`0x6d0a`)
|
||||
@@ -228,11 +238,24 @@ specing reply/like/follow.
|
||||
(`Failed to broadcast: <msg>`). Keep that behavior in specs.
|
||||
6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely
|
||||
on user-provided behavior details and the protocol spec.
|
||||
7. **Byte vs char:** the 217 limit and the counter currently count characters
|
||||
(`input.length`, UTF-16), not bytes. The user is aware; multi-byte unicode may
|
||||
diverge. Ask/decide per feature.
|
||||
7. **Byte vs char:** the 217 post limit and its counter count characters (`input.length`,
|
||||
UTF-16), not bytes. The user is aware; multi-byte unicode may diverge. **Set Name
|
||||
(`0x6d01`) uses BYTE counting (77 bytes) for memo.cash parity** — its byte counter and
|
||||
length check use UTF-8 byte length. Ask/decide per feature.
|
||||
8. **Live backend for e2e:** `https://memo-api.fullstackcash.net/` (prod memo-db). The
|
||||
user can provide BCH for real broadcasts.
|
||||
9. **memo.cash login is Cloudflare-blocked for automation:** the `/login` page shows a
|
||||
hard Turnstile challenge that does not auto-resolve, even with a persistent Playwright
|
||||
profile. The public pages (home, `/all` feed, `/post/<txid>`) DO resolve with a
|
||||
persistent profile (`launchPersistentContext` + `--headless=new` +
|
||||
`--disable-blink-features=AutomationControlled` + realistic UA). To explore the
|
||||
logged-in UI, either solve Turnstile (real session / captcha service) or get
|
||||
screenshots/HTML from the user. The reply UI was captured from public feed/post pages
|
||||
plus reverse-engineering `https://memo.cash/js/min.js`:
|
||||
- login flow `POST /login/submit {username,password,rid,loginToken}` → `SessionKey`;
|
||||
- reply submit `memo/reply-submit` with `{txHash,message}`;
|
||||
- `MaxSize.Reply = 184`; reply form = Message label + `[remaining]` byte counter +
|
||||
textarea + "Post Reply"/"Cancel" + "Creating..."/"Processing..." states.
|
||||
|
||||
---
|
||||
|
||||
@@ -256,4 +279,7 @@ At the end of each session, update this file:
|
||||
- Mark features completed in the backlog (§5).
|
||||
- Add any new gotchas to §10.
|
||||
- Note the current `feat1` HEAD commit.
|
||||
- State the next feature to work on (currently: **Set display name, `0x6d01`**).
|
||||
- State the next feature to work on (currently: **Like / tip a Memo, `0x6d04`**).
|
||||
|
||||
Current `display-name` HEAD: `93e96e7` (Reply to a Memo merged).
|
||||
Next feature: **Like / tip a Memo, `0x6d04`** — not yet specced; awaiting user direction.
|
||||
|
||||
@@ -63,7 +63,7 @@ action plus its read/display surface. This is the recommended first development
|
||||
| # | Feature | Memo action | Write | Read surface |
|
||||
|---|---------|-------------|-------|--------------|
|
||||
| 1 | Post a Memo | `0x6d02` | Compose + `sendOpReturn` | Appears in recent feed & own profile after indexing |
|
||||
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed |
|
||||
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed | ✅ DONE |
|
||||
| 3 | Reply to a Memo | `0x6d03` | Broadcast reply to parent txid | Nested thread view |
|
||||
| 4 | Like a Memo | `0x6d04` | Broadcast like for a post txid | Like count + liked state on post |
|
||||
| 5 | Set profile text (bio) | `0x6d05` | Broadcast bio | Shown on profile page |
|
||||
@@ -77,9 +77,14 @@ follower/following lists; name + profile + avatar joined into feed/profile respo
|
||||
|
||||
## Priority order within P1
|
||||
|
||||
1. **Post a Memo** — the primary verb; unblocks all others.
|
||||
2. **Set display name** — makes the feed readable and gives identity.
|
||||
3. **Reply to a Memo** — core conversation; extends the existing thread modal.
|
||||
1. **Post a Memo** — the primary verb; unblocks all others. ✅ DONE
|
||||
2. **Set display name** — makes the feed readable and gives identity. ✅ DONE
|
||||
3. **Reply to a Memo** — core conversation; extends the existing thread modal. ✅ DONE
|
||||
- **Decisions (2026-08-26, from memo.cash UI review):** reply max = **184 bytes**
|
||||
(UTF-8 byte count); reply form **inside the thread modal**; keep the existing
|
||||
comment-icon behavior (opens the thread modal); replicate the live `[remaining]`
|
||||
byte counter (turns red when over); update the thread **optimistically** after
|
||||
broadcast; users can **reply to a reply** (nested).
|
||||
4. **Like a Memo** — social signal; needs like-count API.
|
||||
5. **Set profile text** — bio for the profile page.
|
||||
6. **Set profile picture** — avatar for posts/profiles.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T00:40:19.414904151Z","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"}]}
|
||||
# {"version":1,"tested_at":"2026-08-26T04:22:55.346096718Z","feature_name":"New Post Page","feature_path":"../../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-26T03:34:47.703957423Z"}]}
|
||||
# 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, New Post Page - 6
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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"}]}
|
||||
# {"version":1,"tested_at":"2026-08-26T04:22:56.345139893Z","feature_name":"Post a Memo","feature_path":"../../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-26T03:34:49.113519078Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T11:43:51.597994833Z","feature_name":"Reply to a Memo","feature_path":"../../specs/reply-memo.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Reply to a Memo - 2 an empty reply is rejected","scenario_hash":"573c450bfab01f83d24545535cdaa4b2bb4c5617c092ff4f1532afa8dc0c20d2","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T11:43:51.597994833Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Reply to a Memo - 1, Reply to a Memo - 2, Reply to a Memo - 3, Reply to a Memo - 4, Reply to a Memo - 5, Reply to a Memo - 6, Reply to a Memo - 7
|
||||
Feature: Reply to a Memo
|
||||
|
||||
Background:
|
||||
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
||||
Given the wallet has spendable output to pay the transaction fee
|
||||
|
||||
Scenario Outline: Reply to a Memo - 1 a valid reply to a post is broadcast and shown in the thread
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
When I type a reply with the text "<message>"
|
||||
When I submit the reply
|
||||
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
|
||||
Then the thread shows a new reply from my address with the text "<message>"
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| hello memo |
|
||||
| a longer reply with several words. |
|
||||
|
||||
Scenario Outline: Reply to a Memo - 2 an empty reply is rejected
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
When I type a reply with the text "<message>"
|
||||
When I submit the reply
|
||||
Then the thread shows a validation error
|
||||
Then the wallet does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| |
|
||||
|
||||
Scenario Outline: Reply to a Memo - 3 an over-long reply is rejected
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
When I type a reply with the text "<message>"
|
||||
When I submit the reply
|
||||
Then the thread shows a length error
|
||||
Then the wallet does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
|
||||
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
|
||||
|
||||
Scenario Outline: Reply to a Memo - 4 the byte counter counts down from the reply limit
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
When I type a reply with the text "<message>"
|
||||
Then the thread shows a remaining byte count of <count>
|
||||
|
||||
Examples:
|
||||
| message | count |
|
||||
| | 184 |
|
||||
| hello | 179 |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
|
||||
|
||||
Scenario Outline: Reply to a Memo - 5 a reply to a nested reply is broadcast
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
And the thread shows a nested reply with the txid bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
When I type a reply to the nested reply with the text "<message>"
|
||||
When I submit the reply
|
||||
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
|
||||
Then the thread shows a new reply from my address with the text "<message>"
|
||||
|
||||
Examples:
|
||||
| message |
|
||||
| hello nested |
|
||||
| a longer nested reply. |
|
||||
|
||||
Scenario: Reply to a Memo - 6 the comment icon opens the thread even when a post has zero replies
|
||||
Given a post with the txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has no replies
|
||||
When I click the comment icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
Then the thread modal opens for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
|
||||
Scenario: Reply to a Memo - 7 the thread modal shows a reply form
|
||||
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
Then the thread modal shows a reply form
|
||||
@@ -0,0 +1,65 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T04:22:57.360768955Z","feature_name":"Set Name","feature_path":"../../specs/set-name.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Set Name - 2 an empty name is rejected on the set name page","scenario_hash":"a4fbf28bd1afbffeff2f24f685299663df57e3cc4332a115fea84c08db634670","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:50.405131105Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Set Name - 1, Set Name - 2, Set Name - 3, Set Name - 4, Set Name - 5
|
||||
Feature: Set Name
|
||||
|
||||
Background:
|
||||
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
|
||||
Given the wallet has spendable output to pay the transaction fee
|
||||
|
||||
Scenario Outline: Set Name - 1 a valid name is broadcast and the user lands on the account page
|
||||
Given I navigate to the path /memo/set-name
|
||||
When I type a name with the text "<name>"
|
||||
When I submit the name
|
||||
Then the app broadcasts an OP_RETURN transaction with the Memo set-name prefix
|
||||
Then I navigate to the path /account
|
||||
Then the account page shows my name as "<name>"
|
||||
|
||||
Examples:
|
||||
| name |
|
||||
| trout |
|
||||
| a longer name with spaces |
|
||||
|
||||
Scenario Outline: Set Name - 2 an empty name is rejected on the set name page
|
||||
Given I navigate to the path /memo/set-name
|
||||
When I type a name with the text "<name>"
|
||||
When I submit the name
|
||||
Then the set name page shows a validation error
|
||||
Then the app does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| name |
|
||||
| |
|
||||
|
||||
Scenario Outline: Set Name - 3 an over-long name is rejected on the set name page
|
||||
Given I navigate to the path /memo/set-name
|
||||
When I type a name with the text "<name>"
|
||||
When I submit the name
|
||||
Then the set name page shows a length error
|
||||
Then the app does not broadcast any transaction
|
||||
|
||||
Examples:
|
||||
| name |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
|
||||
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
|
||||
| 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀 |
|
||||
|
||||
Scenario Outline: Set Name - 4 the byte counter counts down from the name limit
|
||||
Given I navigate to the path /memo/set-name
|
||||
When I type a name with the text "<name>"
|
||||
Then the set name page shows a remaining byte count of <count>
|
||||
|
||||
Examples:
|
||||
| name | count |
|
||||
| | 77 |
|
||||
| trout | 72 |
|
||||
| é | 75 |
|
||||
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
|
||||
|
||||
Scenario: Set Name - 5 the account page links to the set name page
|
||||
Given I navigate to the path /account
|
||||
Then the account page shows a Set Name button
|
||||
When I click the Set Name button
|
||||
Then I navigate to the path /memo/set-name
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Account view: show the authenticated user's display name and offer a button
|
||||
to navigate to the Set Name page.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Button, Spinner } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import AccountPage from '../../../services/account-page'
|
||||
import { truncateAddr } from '../../../util'
|
||||
|
||||
function Account (props) {
|
||||
const { appData } = props
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [name, setName] = useState(null)
|
||||
|
||||
const wallet = appData?.wallet
|
||||
const address = wallet?.walletInfo?.cashAddress || ''
|
||||
|
||||
useEffect(() => {
|
||||
const loadName = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const memoDb = new MemoDb()
|
||||
const profile = await memoDb.getName(address)
|
||||
setName(profile?.name || null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load name')
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (address) {
|
||||
loadName()
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [address])
|
||||
|
||||
const accountPage = new AccountPage({
|
||||
wallet,
|
||||
profiles: appData?.profiles,
|
||||
navigate
|
||||
})
|
||||
|
||||
const displayName = name || accountPage.getName() || truncateAddr(address, 24)
|
||||
|
||||
return (
|
||||
<Container className='account-page mt-4'>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8} md={10} xs={12}>
|
||||
<h1>Account</h1>
|
||||
|
||||
{error && <p className='text-danger'>{error}</p>}
|
||||
|
||||
{loading && (
|
||||
<div className='text-center my-5'>
|
||||
<Spinner animation='border' role='status' variant='primary'>
|
||||
<span className='visually-hidden'>Loading...</span>
|
||||
</Spinner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<div className='account-details'>
|
||||
<p className='account-name'>
|
||||
<strong>Name: </strong>
|
||||
{displayName}
|
||||
</p>
|
||||
<p className='account-address'>
|
||||
<strong>Address: </strong>
|
||||
{address}
|
||||
</p>
|
||||
|
||||
{accountPage.hasSetNameButton() && (
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => accountPage.clickSetName()}
|
||||
>
|
||||
Set Name
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Account
|
||||
@@ -27,6 +27,8 @@ import RecentProfiles from './recent-profiles'
|
||||
import RecentPosts from './posts'
|
||||
import NewPost from './new-post'
|
||||
import Profile from './profile'
|
||||
import SetName from './set-name'
|
||||
import Account from './account'
|
||||
|
||||
function AppBody (props) {
|
||||
// Dependency injection through props
|
||||
@@ -44,6 +46,8 @@ function AppBody (props) {
|
||||
<Route path='/profile/:addr' element={<Profile />} />
|
||||
<Route path='/posts/recent' element={<RecentPosts />} />
|
||||
<Route path='/posts/new' element={<NewPost appData={appData} />} />
|
||||
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
|
||||
<Route path='/account' element={<Account appData={appData} />} />
|
||||
<Route path='/placeholder2' element={<Placeholder2 />} />
|
||||
<Route path='/placeholder3' element={<Placeholder3 />} />
|
||||
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
|
||||
|
||||
@@ -19,7 +19,8 @@ import '../../post-feed/post-feed.css'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
function RecentPosts () {
|
||||
function RecentPosts (props) {
|
||||
const { appData } = props
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [posts, setPosts] = useState([])
|
||||
@@ -163,6 +164,8 @@ function RecentPosts () {
|
||||
show={showThreadModal}
|
||||
txid={threadTxid}
|
||||
onHide={closeThread}
|
||||
wallet={appData?.wallet}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -44,7 +44,8 @@ function ProfileAvatar ({ addr, profilePicUrl }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Profile () {
|
||||
function Profile (props) {
|
||||
const { appData } = props
|
||||
const { addr: encodedAddr } = useParams()
|
||||
const addr = decodeURIComponent(encodedAddr || '')
|
||||
|
||||
@@ -56,6 +57,7 @@ function Profile () {
|
||||
const [pagination, setPagination] = useState(null)
|
||||
const [threadTxid, setThreadTxid] = useState(null)
|
||||
const [showThreadModal, setShowThreadModal] = useState(false)
|
||||
const [profiles, setProfiles] = useState({})
|
||||
|
||||
const openThread = (txid) => {
|
||||
setThreadTxid(txid)
|
||||
@@ -84,6 +86,7 @@ function Profile () {
|
||||
setProfilePicUrl(profilePic?.url || null)
|
||||
setPosts(postsData.posts || [])
|
||||
setPagination(postsData.pagination || null)
|
||||
setProfiles({}) // Future: load profile names for the post list.
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load profile')
|
||||
}
|
||||
@@ -166,6 +169,8 @@ function Profile () {
|
||||
show={showThreadModal}
|
||||
txid={threadTxid}
|
||||
onHide={closeThread}
|
||||
wallet={appData?.wallet}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Set Name view: compose and broadcast a Memo display name, with a byte counter
|
||||
that counts down from the name limit. On success the user is navigated to
|
||||
the account page.
|
||||
*/
|
||||
|
||||
// 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 MemoSetName from '../../../services/memo-set-name'
|
||||
import SetNamePage from '../../../services/set-name-page'
|
||||
import { byteLength } from '../../../services/utf8'
|
||||
|
||||
function SetName (props) {
|
||||
const { appData } = props
|
||||
const navigate = useNavigate()
|
||||
|
||||
const maxBytes = MemoSetName.MAX_NAME_BYTES
|
||||
const [input, setInput] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [settingName, setSettingName] = useState(false)
|
||||
|
||||
const remaining = maxBytes - byteLength(input)
|
||||
|
||||
async function handleSubmit (event) {
|
||||
event.preventDefault()
|
||||
setErr('')
|
||||
setSettingName(true)
|
||||
|
||||
try {
|
||||
const memoSetName = new MemoSetName({ wallet: appData?.wallet, profiles: appData?.profiles })
|
||||
const page = new SetNamePage({ memoSetName, navigate })
|
||||
page.setInput(input)
|
||||
|
||||
const result = await page.submit()
|
||||
if (!result.ok) {
|
||||
if (result.error === 'name_length') {
|
||||
setErr(`Name is too long. Maximum is ${maxBytes} bytes.`)
|
||||
} else if (result.error === 'name_validation') {
|
||||
setErr('Name must not be empty.')
|
||||
} else if (result.message) {
|
||||
setErr(`Failed to broadcast: ${result.message}`)
|
||||
} else {
|
||||
setErr('Failed to set name.')
|
||||
}
|
||||
}
|
||||
// On success page.submit() navigated to the account page.
|
||||
} catch (submitErr) {
|
||||
setErr(submitErr.message)
|
||||
} finally {
|
||||
setSettingName(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Row className='justify-content-center'>
|
||||
<Col lg={8} md={10} xs={12}>
|
||||
<header className='set-name-heading'>
|
||||
<h1>Set Name</h1>
|
||||
<p>Choose a display name and publish it to Bitcoin Cash.</p>
|
||||
</header>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Group controlId='set-name-input' className='mb-3'>
|
||||
<Form.Label><b>Name</b></Form.Label>
|
||||
<Form.Control
|
||||
type='text'
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder='Enter your display name...'
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<p className='set-name-counter'>
|
||||
{remaining} bytes remaining
|
||||
</p>
|
||||
|
||||
{err && <p className='set-name-error'>{err}</p>}
|
||||
|
||||
<Button type='submit' variant='primary' disabled={settingName}>
|
||||
{settingName ? 'Setting Name...' : 'Set Name'}
|
||||
</Button>
|
||||
</Form>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default SetName
|
||||
@@ -94,6 +94,14 @@ function NavMenu (props) {
|
||||
>
|
||||
Check Balance
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={(currentPath === '/account') ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/account'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Account
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={(currentPath === '/sweep') ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/sweep'
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
/*
|
||||
Reply count indicator for a post (icon + number).
|
||||
|
||||
The indicator is always clickable when an onClick handler is provided, even
|
||||
if the count is zero, so that the comment icon can open a thread with zero
|
||||
replies. When onClick is absent, the indicator is rendered as non-interactive.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
@@ -10,8 +14,9 @@ import './post-reply-count.css'
|
||||
|
||||
function PostReplyCount ({ count = 0, onClick }) {
|
||||
const label = count === 1 ? '1 reply' : `${count} replies`
|
||||
const clickable = count > 0 && typeof onClick === 'function'
|
||||
const clickable = typeof onClick === 'function'
|
||||
const title = clickable ? `${label} — click to view` : label
|
||||
const ariaLabel = clickable ? `${label} — click to view thread` : label
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (clickable && (event.key === 'Enter' || event.key === ' ')) {
|
||||
@@ -20,11 +25,16 @@ function PostReplyCount ({ count = 0, onClick }) {
|
||||
}
|
||||
}
|
||||
|
||||
const className = [
|
||||
'post-reply-count',
|
||||
clickable ? 'post-reply-count-always-clickable' : 'post-reply-count-disabled'
|
||||
].join(' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`post-reply-count${clickable ? ' post-reply-count-clickable' : ''}`}
|
||||
className={className}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-label={ariaLabel}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? onClick : undefined}
|
||||
|
||||
@@ -11,10 +11,17 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.post-reply-count-clickable {
|
||||
.post-reply-count-clickable,
|
||||
.post-reply-count-always-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-reply-count-clickable:hover {
|
||||
.post-reply-count-clickable:hover,
|
||||
.post-reply-count-always-clickable:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.post-reply-count-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -7,15 +7,24 @@ import { Modal, Spinner } from 'react-bootstrap'
|
||||
|
||||
import MemoDb from '../../services/memo-db'
|
||||
import PostThreadNode from './post-thread-node'
|
||||
import ReplyThreadForm from './reply-thread-form'
|
||||
import { collectThreadAddrs, loadThreadProfiles } from './thread-profiles'
|
||||
import './post-thread-modal.css'
|
||||
import '../post-feed/post-feed.css'
|
||||
|
||||
function PostThreadModal ({ show, txid, onHide }) {
|
||||
function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfiles }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [thread, setThread] = useState(null)
|
||||
const [profiles, setProfiles] = useState({})
|
||||
const [optimisticReplies, setOptimisticReplies] = useState([])
|
||||
|
||||
// Clear optimistic replies when the modal is hidden or the txid changes.
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setOptimisticReplies([])
|
||||
}
|
||||
}, [show])
|
||||
|
||||
useEffect(() => {
|
||||
if (!show || !txid) {
|
||||
@@ -70,9 +79,14 @@ function PostThreadModal ({ show, txid, onHide }) {
|
||||
setThread(null)
|
||||
setProfiles({})
|
||||
setError(null)
|
||||
setOptimisticReplies([])
|
||||
onHide()
|
||||
}
|
||||
|
||||
const handleOptimisticReply = (reply) => {
|
||||
setOptimisticReplies((prev) => [...prev, reply])
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleHide} size='lg' scrollable centered>
|
||||
<Modal.Header closeButton>
|
||||
@@ -92,7 +106,23 @@ function PostThreadModal ({ show, txid, onHide }) {
|
||||
)}
|
||||
|
||||
{!loading && !error && thread && (
|
||||
<>
|
||||
<PostThreadNode post={thread} profiles={profiles} isRoot />
|
||||
<ReplyThreadForm
|
||||
parentTxid={txid}
|
||||
rootPost={thread}
|
||||
wallet={wallet}
|
||||
profiles={profiles}
|
||||
onOptimisticReply={handleOptimisticReply}
|
||||
/>
|
||||
{optimisticReplies.map((reply) => (
|
||||
<PostThreadNode
|
||||
key={reply.txid}
|
||||
post={reply}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
.reply-thread-form {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.reply-thread-counter {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.reply-thread-counter-over {
|
||||
color: #dc3545;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reply-thread-error {
|
||||
color: #dc3545;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.post-thread-modal-body {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Reply form rendered inside the post thread modal.
|
||||
|
||||
Composes and broadcasts a Memo reply (0x6d03) to the displayed post,
|
||||
with a live byte counter counting down from the 184-byte reply limit.
|
||||
On success, the reply is added to the thread optimistically so the user
|
||||
sees it immediately without waiting for the network crawl/index cycle.
|
||||
|
||||
The wallet and optional profile store are injected through props.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Form, Button } from 'react-bootstrap'
|
||||
|
||||
import MemoReply from '../../services/memo-reply'
|
||||
import ReplyThreadPage from '../../services/reply-thread-page'
|
||||
import { byteLength } from '../../services/utf8'
|
||||
import { buildOptimisticReply } from '../../services/optimistic-reply'
|
||||
|
||||
function ReplyThreadForm ({ parentTxid, rootPost, wallet, profiles, onOptimisticReply }) {
|
||||
const maxBytes = MemoReply.MAX_REPLY_BYTES
|
||||
const [input, setInput] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const [replying, setReplying] = useState(false)
|
||||
|
||||
const remaining = maxBytes - byteLength(input)
|
||||
const overLimit = remaining < 0
|
||||
|
||||
async function handleSubmit (event) {
|
||||
event.preventDefault()
|
||||
setErr('')
|
||||
setReplying(true)
|
||||
|
||||
try {
|
||||
const memoReply = new MemoReply({ wallet, thread: null })
|
||||
const page = new ReplyThreadPage({ memoReply })
|
||||
page.setParent(parentTxid)
|
||||
page.setInput(input)
|
||||
|
||||
const result = await page.submit()
|
||||
if (result.ok) {
|
||||
setInput('')
|
||||
if (typeof onOptimisticReply === 'function') {
|
||||
const cashAddress = wallet?.walletInfo?.cashAddress
|
||||
const displayName = profiles?.[cashAddress]?.name || null
|
||||
onOptimisticReply(buildOptimisticReply({
|
||||
txid: result.txid,
|
||||
addr: cashAddress,
|
||||
text: input,
|
||||
seen: Date.now(),
|
||||
blockHeight: rootPost?.blockHeight,
|
||||
displayName
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
if (result.error === 'reply_length') {
|
||||
setErr(`Reply is too long. Maximum is ${maxBytes} bytes.`)
|
||||
} else if (result.error === 'reply_validation') {
|
||||
setErr('Reply must not be empty.')
|
||||
} else if (result.message) {
|
||||
setErr(`Failed to broadcast: ${result.message}`)
|
||||
} else {
|
||||
setErr('Failed to post reply.')
|
||||
}
|
||||
}
|
||||
} catch (submitErr) {
|
||||
setErr(submitErr.message)
|
||||
} finally {
|
||||
setReplying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className='reply-thread-form' data-testid='reply-thread-form'>
|
||||
<Form.Group controlId='reply-thread-message' className='mb-2'>
|
||||
<Form.Label><b>Reply</b></Form.Label>
|
||||
<Form.Control
|
||||
as='textarea'
|
||||
rows={3}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder='Write a reply...'
|
||||
disabled={replying}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<p className={`reply-thread-counter${overLimit ? ' reply-thread-counter-over' : ''}`}>
|
||||
{remaining} bytes remaining
|
||||
</p>
|
||||
|
||||
{err && <p className='reply-thread-error'>{err}</p>}
|
||||
|
||||
<Button type='submit' variant='primary' disabled={replying || overLimit || byteLength(input) === 0}>
|
||||
{replying ? 'Posting Reply...' : 'Post Reply'}
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReplyThreadForm
|
||||
@@ -2,9 +2,12 @@ import { useState } from 'react'
|
||||
// import { useQueryParam, StringParam } from 'use-query-params'
|
||||
import useLocalStorageState from 'use-local-storage-state'
|
||||
import AppUtil from '../util'
|
||||
import Profiles from '../services/profiles'
|
||||
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const defaultProfiles = new Profiles()
|
||||
|
||||
function useAppState () {
|
||||
const location = useLocation()
|
||||
|
||||
@@ -150,6 +153,7 @@ function useAppState () {
|
||||
updateLocalStorage,
|
||||
updateBchWalletState,
|
||||
appUtil: new AppUtil(),
|
||||
profiles: defaultProfiles,
|
||||
currentPath: location.pathname,
|
||||
setIsSingleView,
|
||||
isSingleView,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Account Page behavior: show the authenticated user's display name and offer
|
||||
a way to navigate to the Set Name page.
|
||||
|
||||
This is the testable controller behind the React "Account" page. It reads
|
||||
the current name from an injected profile store and exposes a Set Name
|
||||
button that navigates to the set-name path.
|
||||
|
||||
The wallet, profile store, 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 SET_NAME_PATH = '/memo/set-name'
|
||||
const ACCOUNT_PATH = '/account'
|
||||
|
||||
class AccountPage {
|
||||
constructor (deps = {}) {
|
||||
this.wallet = deps.wallet || null
|
||||
this.profiles = deps.profiles || null
|
||||
this.navigate = deps.navigate || (() => {})
|
||||
}
|
||||
|
||||
// The address of the authenticated wallet, or null when no wallet is present.
|
||||
getAddress () {
|
||||
return this.wallet?.walletInfo?.cashAddress || null
|
||||
}
|
||||
|
||||
// The current display name for the authenticated address. Falls back to null
|
||||
// when no wallet, profile store, or stored name exists.
|
||||
getName () {
|
||||
const address = this.getAddress()
|
||||
if (!address || !this.profiles || typeof this.profiles.getName !== 'function') {
|
||||
return null
|
||||
}
|
||||
return this.profiles.getName(address)
|
||||
}
|
||||
|
||||
// Whether the account page exposes a Set Name button.
|
||||
hasSetNameButton () {
|
||||
return true
|
||||
}
|
||||
|
||||
// Click the Set Name button: navigate to the set-name page.
|
||||
clickSetName () {
|
||||
this.navigate(SET_NAME_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
AccountPage.SET_NAME_PATH = SET_NAME_PATH
|
||||
AccountPage.ACCOUNT_PATH = ACCOUNT_PATH
|
||||
|
||||
module.exports = AccountPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:43:18.387Z","module_hash":"956a690653185cdbda205b7ee5124f905241d47660357f40e32ee2a9f2340ea9","functions":[{"id":"func/AccountPage.constructor","name":"AccountPage.constructor","line":18,"end_line":22,"hash":"89f261283d2ceab1023088c80e89e48e21d1b62dbc2241a6e9fb00b5653d0607"},{"id":"func/AccountPage.getAddress","name":"AccountPage.getAddress","line":25,"end_line":27,"hash":"dd06e8414856559223a8fd5bd68193d8e04ea6264e3ac7c08e80c8dea69e2a36"},{"id":"func/AccountPage.getName","name":"AccountPage.getName","line":31,"end_line":37,"hash":"63f3f003cea554075f50c92062da81de964fc5cedefbf871843d9dd871aaed17"},{"id":"func/AccountPage.hasSetNameButton","name":"AccountPage.hasSetNameButton","line":40,"end_line":42,"hash":"49dc20060d4c55606057a926132f0cc5c8154548a445b299927ef68b9da86ca3"},{"id":"func/AccountPage.clickSetName","name":"AccountPage.clickSetName","line":45,"end_line":47,"hash":"82ff3b1da4068cbb8b78d55a9dfbd366c78927b67c7d4aa96c4cda12e3144f38"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Shared base for Memo protocol actions that broadcast an OP_RETURN
|
||||
transaction through a wallet and reflect the result on an injected store.
|
||||
|
||||
Subclasses supply the protocol-specific pieces, either as a static
|
||||
`config` object (prefix, walletRequiredMsg, lengthMessage, emptyMessage,
|
||||
lengthCode, validationCode) or as methods:
|
||||
isTooLong(value) - true when the value exceeds the action's limit
|
||||
reflect(txid, value) - record the broadcast result on the injected store
|
||||
*/
|
||||
|
||||
class MemoAction {
|
||||
constructor (deps = {}) {
|
||||
this.wallet = deps.wallet
|
||||
const cfg = this.constructor.config
|
||||
this.prefix = cfg.prefix
|
||||
this.walletRequiredMsg = cfg.walletRequiredMsg
|
||||
this.lengthMessage = cfg.lengthMessage
|
||||
this.emptyMessage = cfg.emptyMessage
|
||||
this.lengthCode = cfg.lengthCode
|
||||
this.validationCode = cfg.validationCode
|
||||
}
|
||||
|
||||
// Validate a candidate value.
|
||||
// Returns { ok: true } or { ok: false, type: 'validation' | 'length' }.
|
||||
validate (value) {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return { ok: false, type: 'validation' }
|
||||
}
|
||||
|
||||
if (this.isTooLong(value)) {
|
||||
return { ok: false, type: 'length' }
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Compose and broadcast the action for the given value.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async broadcast (value) {
|
||||
const check = this.validate(value)
|
||||
this._throwIfInvalid(check)
|
||||
|
||||
if (!this.wallet) {
|
||||
throw new Error(this.walletRequiredMsg)
|
||||
}
|
||||
|
||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and its
|
||||
// own spendable UTXOs internally, so only the value and prefix are passed.
|
||||
const txid = await this.wallet.sendOpReturn(value, this.prefix)
|
||||
|
||||
// Reflect the result on the injected store once broadcast succeeds.
|
||||
this.reflect(txid, value)
|
||||
|
||||
return txid
|
||||
}
|
||||
|
||||
// Throw the appropriate typed error when a value fails validation.
|
||||
_throwIfInvalid (check) {
|
||||
if (check.ok) return
|
||||
|
||||
const err = new Error(
|
||||
check.type === 'length' ? this.lengthMessage : this.emptyMessage
|
||||
)
|
||||
err.code = check.type === 'length' ? this.lengthCode : this.validationCode
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MemoAction
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:41:40.313Z","module_hash":"9f6ac3a351ce499162bd5f350ac2eec15f7a4a88450334b49cb1c445b83b0eea","functions":[{"id":"func/MemoAction.constructor","name":"MemoAction.constructor","line":13,"end_line":22,"hash":"881f01aa2a258bcbc4750b69dc303a03139b6368decb2e10e667dc2f23f5ea80"},{"id":"func/MemoAction.validate","name":"MemoAction.validate","line":26,"end_line":36,"hash":"b8598a392b3a65b5f1fe329048a041a087ef0735806fd03f42fe0cf7e19ef7fc"},{"id":"func/MemoAction.broadcast","name":"MemoAction.broadcast","line":40,"end_line":59,"hash":"07853c0eec474cae372e901db388b62b50020db0aff1f63bb587b9e494f4ede5"},{"id":"func/MemoAction._throwIfInvalid","name":"MemoAction._throwIfInvalid","line":62,"end_line":70,"hash":"dafb785969f30b0fa347c8e699e4bf3302ce3a9ef0d3481f1ffa5c276992c808"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
+19
-48
@@ -16,68 +16,39 @@
|
||||
218 rejected)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
|
||||
const MEMO_POST_PREFIX = '6d02'
|
||||
const MAX_MEMO_CHARS = 217
|
||||
|
||||
class MemoPost {
|
||||
class MemoPost extends MemoAction {
|
||||
static config = {
|
||||
prefix: MEMO_POST_PREFIX,
|
||||
walletRequiredMsg: 'Memo post requires a wallet.',
|
||||
lengthMessage: `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.`,
|
||||
emptyMessage: 'Memo must not be empty.',
|
||||
lengthCode: 'memo_length',
|
||||
validationCode: 'memo_validation'
|
||||
}
|
||||
|
||||
constructor (deps = {}) {
|
||||
this.wallet = deps.wallet
|
||||
super(deps)
|
||||
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 }
|
||||
// A memo is over-length when it exceeds the character limit.
|
||||
isTooLong (message) {
|
||||
return message.length > MAX_MEMO_CHARS
|
||||
}
|
||||
|
||||
// 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.')
|
||||
}
|
||||
|
||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// The wallet's public sendOpReturn(msg, prefix) resolves walletInfo and
|
||||
// its own spendable UTXOs internally, so only the message and Memo post
|
||||
// prefix are passed here.
|
||||
const txid = await this.wallet.sendOpReturn(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
|
||||
return this.broadcast(message)
|
||||
}
|
||||
|
||||
// Record the new post on the injected feed when one is present.
|
||||
_reflectPost (txid, message) {
|
||||
reflect (txid, message) {
|
||||
if (this.feed && typeof this.feed.addPost === 'function') {
|
||||
this.feed.addPost({
|
||||
txid,
|
||||
@@ -94,5 +65,5 @@ 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"}]}
|
||||
// {"version":1,"tested_at":"2026-08-26T11:41:52.322Z","module_hash":"ef34e1b3318b764dab099f693855e5f57704d60b2173e99c146bdbf34b6dd8c5","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":34,"end_line":37,"hash":"527e19b059e463a67be214a5c77c0ce4261ffadb58b9546b422be543c1df292d"},{"id":"func/MemoPost.isTooLong","name":"MemoPost.isTooLong","line":40,"end_line":42,"hash":"833f9f66eae849df0248d0c696c95c767a121b394b1c728bc3ec675668dff5de"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":46,"end_line":48,"hash":"26d8e84b520fae1928f7f72215e6ed95f7219c14266c99b710e2af5373cb6faf"},{"id":"func/MemoPost.reflect","name":"MemoPost.reflect","line":51,"end_line":59,"hash":"87e2168a71309a572c60b63f382dd6f681cb28ed543090dbde399e29556cfcdf"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Memo reply behavior: compose, validate, and broadcast a Memo "reply" message.
|
||||
|
||||
A Memo reply is an OP_RETURN Bitcoin Cash transaction carrying the Memo reply
|
||||
protocol prefix (0x6d03) followed by the parent transaction hash (32 bytes)
|
||||
and the reply message text. Broadcasting is done through a wallet that
|
||||
exposes the minimal-slp-wallet adapter surface (walletInfo, getUtxos(),
|
||||
sendOpReturn()).
|
||||
|
||||
The wallet and thread 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_REPLY_PREFIX : hex prefix for the Memo "reply" action (0x6d03)
|
||||
MAX_REPLY_BYTES : maximum allowed reply text length (184 bytes)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { byteLength } = require('./utf8')
|
||||
|
||||
const MEMO_REPLY_PREFIX = '6d03'
|
||||
const MAX_REPLY_BYTES = 184
|
||||
const PARENT_TXID_BYTES = 32
|
||||
|
||||
class MemoReply extends MemoAction {
|
||||
static config = {
|
||||
prefix: MEMO_REPLY_PREFIX,
|
||||
walletRequiredMsg: 'Memo reply requires a wallet.',
|
||||
lengthMessage: `Reply is too long. Maximum is ${MAX_REPLY_BYTES} bytes.`,
|
||||
emptyMessage: 'Reply must not be empty.',
|
||||
lengthCode: 'reply_length',
|
||||
validationCode: 'reply_validation'
|
||||
}
|
||||
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.thread = deps.thread
|
||||
}
|
||||
|
||||
// A reply is over-length when its UTF-8 byte count exceeds the limit.
|
||||
isTooLong (message) {
|
||||
return byteLength(message) > MAX_REPLY_BYTES
|
||||
}
|
||||
|
||||
// Compose and broadcast a Memo reply for the given message and parent txid.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async reply (message, parentTxid) {
|
||||
const check = this.validate(message)
|
||||
this._throwIfInvalid(check)
|
||||
|
||||
if (!this.wallet) {
|
||||
throw new Error(this.walletRequiredMsg)
|
||||
}
|
||||
|
||||
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
|
||||
const raw = buildReplyPayload(parentTxid, message)
|
||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
||||
|
||||
// Reflect the result on the injected thread once broadcast succeeds.
|
||||
this.reflect(txid, message, parentTxid)
|
||||
|
||||
return txid
|
||||
}
|
||||
|
||||
// Record the new reply on the injected thread store when one is present.
|
||||
reflect (txid, message, parentTxid) {
|
||||
if (this.thread && typeof this.thread.addReply === 'function') {
|
||||
this.thread.addReply({
|
||||
txid,
|
||||
address: this.wallet.walletInfo.cashAddress,
|
||||
text: message,
|
||||
parentTxid
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the raw OP_RETURN message payload for a reply.
|
||||
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
|
||||
function buildReplyPayload (parentTxid, message) {
|
||||
const parentBytes = hexToBytes(parentTxid)
|
||||
const textBytes = new TextEncoder().encode(message)
|
||||
const raw = new Uint8Array(parentBytes.length + textBytes.length)
|
||||
raw.set(parentBytes, 0)
|
||||
raw.set(textBytes, parentBytes.length)
|
||||
return raw
|
||||
}
|
||||
|
||||
// Decode a 64-character hex transaction id into 32 raw bytes.
|
||||
function hexToBytes (hex) {
|
||||
if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) {
|
||||
throw new Error('Parent txid must be a 64-character hex string.')
|
||||
}
|
||||
const bytes = new Uint8Array(PARENT_TXID_BYTES)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
const byte = parseInt(hex.substr(i, 2), 16)
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error('Parent txid must be a valid hex string.')
|
||||
}
|
||||
bytes[i / 2] = byte
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
|
||||
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
|
||||
|
||||
module.exports = MemoReply
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:42:09.351Z","module_hash":"3eb068f9f90f27c5d7acf2bb5a6c517d1085bcb379123ca2d7134d4fb48a092b","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"2d7b425e350b640caf6ca821146065e9c21fe56f1e8a9d8b1f6cab5504a523c1"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"},{"id":"func/buildReplyPayload","name":"buildReplyPayload","line":84,"end_line":91,"hash":"ef9ee77938593f1dbf2d168c20ea4f8dee0300f64f0fdb4f06a4ba647bb782d5"},{"id":"func/hexToBytes","name":"hexToBytes","line":94,"end_line":107,"hash":"29b401020452eabcb1b54634029d8015758b77b536be3d1ed508e9d560ac93b1"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Memo set-name behavior: compose, validate, and broadcast a Memo "set name"
|
||||
message.
|
||||
|
||||
A Memo set-name transaction is an OP_RETURN Bitcoin Cash transaction carrying
|
||||
the Memo set-name protocol prefix (0x6d01) followed by the name text.
|
||||
Broadcasting is done through a wallet that exposes the minimal-slp-wallet
|
||||
adapter surface (walletInfo, getUtxos(), sendOpReturn()).
|
||||
|
||||
The wallet and profiles store 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_SET_NAME_PREFIX : hex prefix for the Memo "set name" action (0x6d01)
|
||||
MAX_NAME_BYTES : maximum allowed name length (77 bytes per memo.sv)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { byteLength } = require('./utf8')
|
||||
|
||||
const MEMO_SET_NAME_PREFIX = '6d01'
|
||||
const MAX_NAME_BYTES = 77
|
||||
|
||||
class MemoSetName extends MemoAction {
|
||||
static config = {
|
||||
prefix: MEMO_SET_NAME_PREFIX,
|
||||
walletRequiredMsg: 'Memo set name requires a wallet.',
|
||||
lengthMessage: `Name is too long. Maximum is ${MAX_NAME_BYTES} bytes.`,
|
||||
emptyMessage: 'Name must not be empty.',
|
||||
lengthCode: 'name_length',
|
||||
validationCode: 'name_validation'
|
||||
}
|
||||
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.profiles = deps.profiles
|
||||
}
|
||||
|
||||
// A name is over-length when it exceeds the byte limit.
|
||||
isTooLong (name) {
|
||||
return byteLength(name) > MAX_NAME_BYTES
|
||||
}
|
||||
|
||||
// Compose and broadcast a Memo set-name transaction for the given name.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async setName (name) {
|
||||
return this.broadcast(name)
|
||||
}
|
||||
|
||||
// Record the new name on the injected profile store when one is present.
|
||||
reflect (txid, name) {
|
||||
if (this.profiles && typeof this.profiles.setName === 'function') {
|
||||
this.profiles.setName(this.wallet.walletInfo.cashAddress, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MemoSetName.MEMO_SET_NAME_PREFIX = MEMO_SET_NAME_PREFIX
|
||||
MemoSetName.MAX_NAME_BYTES = MAX_NAME_BYTES
|
||||
|
||||
module.exports = MemoSetName
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:42:00.746Z","module_hash":"98a611f25cf764ac9f182aa9ceb60e0d6ea750d391ba26562c034ee84ef4a9ae","functions":[{"id":"func/MemoSetName.constructor","name":"MemoSetName.constructor","line":35,"end_line":38,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoSetName.isTooLong","name":"MemoSetName.isTooLong","line":41,"end_line":43,"hash":"e25b4701e1bf64f197980310a29c195a7f3b429ea41be4f01732541c5a9b7cfc"},{"id":"func/MemoSetName.setName","name":"MemoSetName.setName","line":47,"end_line":49,"hash":"9226b63b60a573a9dfb5c7bbb1449d1a138f30b648642d345c5162d012a2cfc0"},{"id":"func/MemoSetName.reflect","name":"MemoSetName.reflect","line":52,"end_line":56,"hash":"3cfed5e8ec7acf592e07e67659b4d8e075d98bbddf79755b8a31b76fd1ae5696"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
+13
-43
@@ -13,21 +13,20 @@
|
||||
adapter boundaries.
|
||||
*/
|
||||
|
||||
const PageController = require('./page-controller')
|
||||
const MemoPost = require('./memo-post')
|
||||
|
||||
const NEW_POST_PATH = '/posts/new'
|
||||
const RECENT_FEED_PATH = '/posts/recent'
|
||||
|
||||
class NewPostPage {
|
||||
class NewPostPage extends PageController {
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.memoPost = deps.memoPost || null
|
||||
this.navigate = deps.navigate || (() => {})
|
||||
this.menuLinks = deps.menuLinks || []
|
||||
|
||||
this.input = ''
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
this.posting = false
|
||||
this.successPath = RECENT_FEED_PATH
|
||||
this.validationCodes = ['memo_validation', 'memo_length']
|
||||
|
||||
// The navigation menu links to the new post page.
|
||||
this.addMenuLink(NEW_POST_PATH)
|
||||
@@ -44,51 +43,22 @@ class NewPostPage {
|
||||
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 and stay on the page. Resolves
|
||||
// with a result object.
|
||||
async submit () {
|
||||
this.posting = true
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
// Set the in-flight posting flag.
|
||||
_setBusy (value) {
|
||||
this.posting = value
|
||||
}
|
||||
|
||||
try {
|
||||
// Run the memo post action for the current input.
|
||||
async _perform (input) {
|
||||
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) {
|
||||
return this._handleSubmitFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify a submit failure, record the typed state, and return the failure
|
||||
// result. Local validation failures set submitError; broadcast or handler
|
||||
// failures surface the real error message via broadcastError.
|
||||
_handleSubmitFailure (err) {
|
||||
if (err.code === 'memo_validation' || err.code === 'memo_length') {
|
||||
this.submitError = err.code
|
||||
} else {
|
||||
this.broadcastError = err.message || String(err)
|
||||
this.submitError = 'broadcast'
|
||||
}
|
||||
this.posting = false
|
||||
return { ok: false, error: this.submitError, message: this.broadcastError }
|
||||
return this.memoPost.post(input)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,5 +68,5 @@ NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||
module.exports = NewPostPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T00:39:54.163Z","module_hash":"8d50d002e9c6094a1bd2d6e764023c942b1eb0f085b019255dc80f0a72ab1ec6","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":34,"hash":"d61d01986c51dc4ed4185594fa3e35612924846db321a7107aaec191d17c419d"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":37,"end_line":40,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":43,"end_line":45,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":48,"end_line":51,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":54,"end_line":56,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":61,"end_line":78,"hash":"c280ee1244bcb80c3a9ffe4f52befc8a05629ec879ef9d86666ea11f493b3c5b"},{"id":"func/NewPostPage._handleSubmitFailure","name":"NewPostPage._handleSubmitFailure","line":83,"end_line":92,"hash":"b13d8cd6b48b1f42d72e0031cabcaa00bb78b813f42250517d711f0d6fb23126"}]}
|
||||
// {"version":1,"tested_at":"2026-08-26T11:42:42.427Z","module_hash":"45d772cfe40b8a34018092abbe924c01cbde741c5cbaa737a283ba35214a99ff","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":23,"end_line":33,"hash":"686653c181941b27c19cd8c1f9fa7b3fee50db6e19c1e39d0db79e6bfbe52a81"},{"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.remainingCount","name":"NewPostPage.remainingCount","line":47,"end_line":49,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":52,"end_line":54,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":57,"end_line":62,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict'
|
||||
|
||||
// Build the optimistic reply object used by the reply form for immediate
|
||||
// thread rendering before the thread is refreshed from the network. This is a
|
||||
// pure data-shaping function so the reply object shape is unit-testable and
|
||||
// the React form stays a thin adapter.
|
||||
function buildOptimisticReply ({ txid, addr, text, seen, blockHeight, displayName }) {
|
||||
return {
|
||||
txid,
|
||||
addr,
|
||||
text,
|
||||
seen,
|
||||
blockHeight,
|
||||
replyCount: 0,
|
||||
replies: [],
|
||||
profile: displayName ? { name: displayName } : undefined
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { buildOptimisticReply }
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:41:34.005Z","module_hash":"feaca4cb374f34e47635d42df6a7459a8dfb623ec2425f85a19de090ab4286e0","functions":[{"id":"func/buildOptimisticReply","name":"buildOptimisticReply","line":7,"end_line":18,"hash":"531eb7347a4652e240f704936b2651e7e04506d6f5c0e10d33b37964be5e400a"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
Shared base for page controllers that compose and submit a single action,
|
||||
surface validation/broadcast errors, and navigate on success.
|
||||
|
||||
Subclasses supply the page-specific pieces:
|
||||
successPath - path to navigate to on success
|
||||
validationCodes - error codes that represent local validation failures
|
||||
_setBusy(value) - set the page's in-flight flag
|
||||
_perform(input) - run the action for the current input, resolving with txid
|
||||
*/
|
||||
|
||||
class PageController {
|
||||
constructor (deps = {}) {
|
||||
this.navigate = deps.navigate || (() => {})
|
||||
this.input = ''
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
}
|
||||
|
||||
// Set the draft input.
|
||||
setInput (text) {
|
||||
this.input = typeof text === 'string' ? text : ''
|
||||
return this
|
||||
}
|
||||
|
||||
// Validate and submit the current input. On success, navigate to the success
|
||||
// path. On failure, record the typed error and stay on the page. Resolves
|
||||
// with a result object.
|
||||
async submit () {
|
||||
this._setBusy(true)
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
|
||||
try {
|
||||
const txid = await this._perform(this.input)
|
||||
if (this.successPath) {
|
||||
this.navigate(this.successPath)
|
||||
}
|
||||
this._setBusy(false)
|
||||
return { ok: true, txid }
|
||||
} catch (err) {
|
||||
return this._handleSubmitFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify a submit failure, record the typed state, and return the failure
|
||||
// result. Local validation failures set submitError; broadcast or handler
|
||||
// failures surface the real error message via broadcastError.
|
||||
_handleSubmitFailure (err) {
|
||||
if (this.validationCodes.includes(err.code)) {
|
||||
this.submitError = err.code
|
||||
} else {
|
||||
this.broadcastError = err.message || String(err)
|
||||
this.submitError = 'broadcast'
|
||||
}
|
||||
this._setBusy(false)
|
||||
return { ok: false, error: this.submitError, message: this.broadcastError }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PageController
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:42:26.217Z","module_hash":"cfaf6bb208fa501d8fefcaa75c6ad1107128ea4b9baf739b43b4e803f28eef9b","functions":[{"id":"func/PageController.constructor","name":"PageController.constructor","line":13,"end_line":18,"hash":"09ba0e480cbc1213c699f45c7b6ef59e584dce4e8d4f1ab0adf5094435eba06f"},{"id":"func/PageController.setInput","name":"PageController.setInput","line":21,"end_line":24,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/PageController.submit","name":"PageController.submit","line":29,"end_line":44,"hash":"bd2acb67a3a3e33cf8ed9f61886796102c1689486a1ea6757153b2be097eb704"},{"id":"func/PageController._handleSubmitFailure","name":"PageController._handleSubmitFailure","line":49,"end_line":58,"hash":"3d9ad3eb3a25e11b8a1eef164b2757034499b85b439754bb606622f99c72d5f9"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Simple in-memory profile store for the current session.
|
||||
|
||||
Holds display names and other profile data indexed by BCH cash address. This
|
||||
keeps the Set Name and Account pages in sync immediately after a name is
|
||||
broadcast, without waiting for the memo-db indexer to crawl the transaction.
|
||||
|
||||
In a production app this would be backed by memo-db or persistent storage;
|
||||
for the current SPA it is a small shared adapter boundary.
|
||||
*/
|
||||
|
||||
class Profiles {
|
||||
constructor () {
|
||||
this.names = new Map()
|
||||
}
|
||||
|
||||
setName (addr, name) {
|
||||
if (!addr) return
|
||||
this.names.set(addr, name)
|
||||
}
|
||||
|
||||
getName (addr) {
|
||||
if (!addr) return null
|
||||
return this.names.get(addr) || null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Profiles
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:43:34.602Z","module_hash":"6a13673cbcae9c1dc6a497b7214409f415a403b9c6eaefd04776950fb8b64768","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":15,"hash":"d13fcf15cca167093fca3cb89c2482d1fbbfac5afad666e4b9cf47a440aa8394"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":17,"end_line":20,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":22,"end_line":25,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
Reply Thread Page behavior: compose and submit a reply inside a thread
|
||||
modal, with a live byte counter that counts down from the reply limit.
|
||||
|
||||
This is the testable controller behind the Reply form in the React thread
|
||||
modal. It wraps the Memo reply behavior (src/services/memo-reply.js) and
|
||||
adds page-level concerns: holding the current input, tracking the parent txid
|
||||
being replied to, computing the remaining byte count, and surfacing
|
||||
validation/length/broadcast errors.
|
||||
|
||||
The memoReply 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 PageController = require('./page-controller')
|
||||
const MemoReply = require('./memo-reply')
|
||||
const { byteLength } = require('./utf8')
|
||||
|
||||
const REPLY_THREAD_PATH = '/posts/thread'
|
||||
|
||||
class ReplyThreadPage extends PageController {
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.memoReply = deps.memoReply || null
|
||||
this.replying = false
|
||||
this.successPath = deps.successPath || null
|
||||
this.validationCodes = ['reply_validation', 'reply_length']
|
||||
this.parentTxid = deps.parentTxid || null
|
||||
}
|
||||
|
||||
// Set the parent txid that the next reply will be attached to.
|
||||
setParent (txid) {
|
||||
this.parentTxid = txid
|
||||
return this
|
||||
}
|
||||
|
||||
// Bytes remaining before the reply byte limit is reached.
|
||||
remainingCount () {
|
||||
return MemoReply.MAX_REPLY_BYTES - byteLength(this.input)
|
||||
}
|
||||
|
||||
// Set the in-flight replying flag.
|
||||
_setBusy (value) {
|
||||
this.replying = value
|
||||
}
|
||||
|
||||
// Run the memo reply action for the current input against the current parent.
|
||||
async _perform (input) {
|
||||
if (!this.memoReply) {
|
||||
throw new Error('Reply thread requires a memo reply handler.')
|
||||
}
|
||||
return this.memoReply.reply(input, this.parentTxid)
|
||||
}
|
||||
}
|
||||
|
||||
ReplyThreadPage.REPLY_THREAD_PATH = REPLY_THREAD_PATH
|
||||
|
||||
module.exports = ReplyThreadPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:43:05.248Z","module_hash":"6a5e24347eb65fe6f046276efeeab797895317e58ac9803800bf3eef5ddbcf90","functions":[{"id":"func/ReplyThreadPage.constructor","name":"ReplyThreadPage.constructor","line":23,"end_line":30,"hash":"b43af0c6321c8f04814d27e71ad868636921162142917594e8a9235cd7b9c926"},{"id":"func/ReplyThreadPage.setParent","name":"ReplyThreadPage.setParent","line":33,"end_line":36,"hash":"2816f5ec8d3c78101df88e2121f42d89007f3a4153f08990dc9b13c40f75d317"},{"id":"func/ReplyThreadPage.remainingCount","name":"ReplyThreadPage.remainingCount","line":39,"end_line":41,"hash":"2e6661f11eb38ed153282f6ff9d2ba0d7bd6123b4e98a5f9a68bef2da13f301c"},{"id":"func/ReplyThreadPage._setBusy","name":"ReplyThreadPage._setBusy","line":44,"end_line":46,"hash":"eab587885393bc07c55e5c7e73fdd200eac659176c2d8dcf60b8e35773a3a6cf"},{"id":"func/ReplyThreadPage._perform","name":"ReplyThreadPage._perform","line":49,"end_line":54,"hash":"640864465ca82bdc624c93b695f8da359ef19d4001e94e1432328fe3486fd14c"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Set Name Page behavior: compose and broadcast a Memo display name, with a
|
||||
byte counter that counts down from the name limit.
|
||||
|
||||
This is the testable controller behind the React "Set Name" page. It wraps
|
||||
the Memo set-name behavior (src/services/memo-set-name.js) and adds page-level
|
||||
concerns: holding the current input, computing the remaining byte count,
|
||||
surfacing validation/length errors, and navigating to the account page after
|
||||
a successful broadcast.
|
||||
|
||||
The memoSetName 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 PageController = require('./page-controller')
|
||||
const MemoSetName = require('./memo-set-name')
|
||||
const { byteLength } = require('./utf8')
|
||||
|
||||
const SET_NAME_PATH = '/memo/set-name'
|
||||
const ACCOUNT_PATH = '/account'
|
||||
|
||||
class SetNamePage extends PageController {
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.memoSetName = deps.memoSetName || null
|
||||
this.settingName = false
|
||||
this.successPath = ACCOUNT_PATH
|
||||
this.validationCodes = ['name_validation', 'name_length']
|
||||
}
|
||||
|
||||
// Bytes remaining before the name limit is reached.
|
||||
remainingCount () {
|
||||
return MemoSetName.MAX_NAME_BYTES - byteLength(this.input)
|
||||
}
|
||||
|
||||
// Set the in-flight setting-name flag.
|
||||
_setBusy (value) {
|
||||
this.settingName = value
|
||||
}
|
||||
|
||||
// Run the memo set-name action for the current input.
|
||||
async _perform (input) {
|
||||
if (!this.memoSetName) {
|
||||
throw new Error('Set name requires a memo set-name handler.')
|
||||
}
|
||||
return this.memoSetName.setName(input)
|
||||
}
|
||||
}
|
||||
|
||||
SetNamePage.SET_NAME_PATH = SET_NAME_PATH
|
||||
SetNamePage.ACCOUNT_PATH = ACCOUNT_PATH
|
||||
|
||||
module.exports = SetNamePage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T11:42:54.394Z","module_hash":"5e4ff62bceef737444b487d65f85608ba84ffc0bac69791d1347f7c1dfd8a300","functions":[{"id":"func/SetNamePage.constructor","name":"SetNamePage.constructor","line":24,"end_line":30,"hash":"4ce053830f485ce8a0fba4495cd85fc5e2cac3c9db88b5cbf1e8c66a371a9752"},{"id":"func/SetNamePage.remainingCount","name":"SetNamePage.remainingCount","line":33,"end_line":35,"hash":"47c88116b836dde07c64ae4de65c89a6d702a7889e032f2df163dd84cf48083f"},{"id":"func/SetNamePage._setBusy","name":"SetNamePage._setBusy","line":38,"end_line":40,"hash":"a0947ed899e0def1f6ae243197d409f5603c4820467f0e0fafdcb4deadb3a92b"},{"id":"func/SetNamePage._perform","name":"SetNamePage._perform","line":43,"end_line":48,"hash":"8614f25b06dcb21b81ab94b4a4611c5e3719ebf4ed7ab13e13976ab0d5938b27"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
UTF-8 byte-length helper for browser and Node.
|
||||
|
||||
The Node global `Buffer` is not available in the browser, so byte counting
|
||||
(used by the Memo set-name byte counter and length check) must not depend on
|
||||
it. TextEncoder is available in both environments and reports the UTF-8 byte
|
||||
length of a string.
|
||||
*/
|
||||
|
||||
// Return the number of UTF-8 bytes in a string.
|
||||
function byteLength (str) {
|
||||
return new TextEncoder().encode(String(str)).length
|
||||
}
|
||||
|
||||
module.exports = { byteLength }
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T04:22:38.473Z","module_hash":"7f91541c49f2b6f421e8d4158bfe808b35a5449534cb26c567162fce6fec64bf","functions":[{"id":"func/byteLength","name":"byteLength","line":11,"end_line":13,"hash":"973c9dadcd1d8bbd53587252443880db13c8be3639fa74ce3c69a08ea358c8e2"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -9,6 +9,6 @@
|
||||
# - architect → qwen-token-plan/glm-5.2 (familia GLM)
|
||||
# Verificar ids con: pi --list-models
|
||||
window specifier pi master --model deepseek-v4-flash:0731-cloud
|
||||
window coder pi coder --model deepseek-v4-flash:0731-cloud
|
||||
window coder pi coder --model kimi-k2.7-code:cloud
|
||||
window refactorer pi refactorer batch --model deepseek-v4-flash:0731-cloud
|
||||
window architect pi architect batch --model deepseek-v4-flash:0731-cloud
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict'
|
||||
|
||||
// A fake profile store that records names set for addresses.
|
||||
function fakeProfiles () {
|
||||
const names = new Map()
|
||||
return {
|
||||
names,
|
||||
setName: (addr, name) => names.set(addr, name),
|
||||
getName: (addr) => names.get(addr) || null
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { fakeProfiles }
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict'
|
||||
|
||||
// A fake wallet that records every broadcast attempt and can be made to fail.
|
||||
// It satisfies the small adapter surface the Memo action modules need:
|
||||
// walletInfo, getUtxos(), sendOpReturn(). Set `wallet.failWith` to make
|
||||
// sendOpReturn throw.
|
||||
function fakeWallet ({
|
||||
cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
|
||||
utxos = [{ txid: 'utxo1' }],
|
||||
txid = 'fake-txid'
|
||||
} = {}) {
|
||||
const broadcasts = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
broadcasts.push({ msg, prefix })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return txid
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
return wallet
|
||||
}
|
||||
|
||||
module.exports = { fakeWallet }
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Shared property tests for the Memo post and Set Name behavior slices.
|
||||
|
||||
Both slices validate a value against a length limit, expose a character/byte
|
||||
counter that conserves its relationship to input length, round-trip the draft
|
||||
text through setInput, and surface broadcast failures without navigating.
|
||||
These tests assert those invariants across a broad input range; the slice
|
||||
specifics are supplied through `cfg` so the two behavior slices share one
|
||||
implementation instead of duplicating it.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
|
||||
const { forAll, makeStringGen } = require('./harness')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
|
||||
// Register the property tests shared by a Memo behavior slice. `cfg` supplies
|
||||
// the slice-specific pieces:
|
||||
// Module - the action class under test (MemoPost or MemoSetName)
|
||||
// MAX - the length limit (characters or bytes)
|
||||
// label - a short label used in test names
|
||||
// rng - the seeded random generator
|
||||
// measure - (input) => length in the slice's unit (chars or bytes)
|
||||
// buildPage - () => a fresh page for counter and round-trip tests
|
||||
// buildBroadcastPage - ({ wallet, navigations }) => a page wired to broadcast
|
||||
function registerBehaviorProperties (cfg) {
|
||||
const { Module, MAX, label, rng, measure, buildPage, buildBroadcastPage } = cfg
|
||||
const stringOf = makeStringGen(rng)
|
||||
|
||||
test(`${label} 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)
|
||||
},
|
||||
(input) => {
|
||||
// A random ASCII string may occasionally be all whitespace; whitespace-only
|
||||
// input is a validation error, so only assert for non-blank strings.
|
||||
if (input.trim().length === 0) return true
|
||||
const result = new Module({}).validate(input)
|
||||
return result.ok === true
|
||||
},
|
||||
{ label: 'valid length' }
|
||||
)
|
||||
})
|
||||
|
||||
test(`${label} validation: any string above the limit is a length error`, async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
|
||||
(input) => {
|
||||
const result = new Module({}).validate(input)
|
||||
return result.ok === false && result.type === 'length'
|
||||
},
|
||||
{ label: 'over-long rejected as length' }
|
||||
)
|
||||
})
|
||||
|
||||
test(`${label} validation: blank and non-string input are validation errors`, async () => {
|
||||
await forAll(
|
||||
(i) => (i % 2 === 0 ? ' ' : null),
|
||||
(input) => {
|
||||
const result = new Module({}).validate(input)
|
||||
return result.ok === false && result.type === 'validation'
|
||||
},
|
||||
{ label: 'blank/non-string rejected as validation' }
|
||||
)
|
||||
})
|
||||
|
||||
test(`${label} counter conserves length: remaining === MAX - measure(input)`, async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(Math.floor(rng() * (MAX + 8))),
|
||||
(input) => {
|
||||
const page = buildPage()
|
||||
page.setInput(input)
|
||||
return page.remainingCount() === MAX - measure(input)
|
||||
},
|
||||
{ label: 'counter conservation' }
|
||||
)
|
||||
})
|
||||
|
||||
test('setInput round-trips the draft text exactly', async () => {
|
||||
await forAll(
|
||||
(i) => stringOf(Math.floor(rng() * 50)),
|
||||
(input) => {
|
||||
const page = buildPage()
|
||||
page.setInput(input)
|
||||
return page.input === input
|
||||
},
|
||||
{ label: 'setInput round-trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('a broadcast failure surfaces the error and never navigates', async () => {
|
||||
await forAll(
|
||||
(i) => ({ text: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }),
|
||||
({ text, failWith }) => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.failWith = failWith
|
||||
const navigations = []
|
||||
const page = buildBroadcastPage({ wallet, navigations })
|
||||
page.setInput(text)
|
||||
|
||||
return page.submit().then((result) => {
|
||||
if (result.ok) return false
|
||||
if (page.submitError !== 'broadcast') return false
|
||||
if (!page.broadcastError || !page.broadcastError.includes('boom')) return false
|
||||
return navigations.length === 0
|
||||
})
|
||||
},
|
||||
{ label: 'broadcast failure does not navigate' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerBehaviorProperties }
|
||||
@@ -2,21 +2,18 @@
|
||||
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.
|
||||
points. The validation, counter-conservation, setInput round-trip, and
|
||||
broadcast-failure invariants are shared with the Set Name slice and live in
|
||||
behavior-helpers.js; this file supplies the Memo-post specifics and the
|
||||
menu-link idempotence property unique to the New Post page.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
|
||||
const { seededRandom, forAll, makeStringGen } = require('./harness')
|
||||
const { seededRandom, forAll } = require('./harness')
|
||||
const { registerBehaviorProperties } = require('./behavior-helpers')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
@@ -24,7 +21,6 @@ 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({
|
||||
@@ -34,86 +30,26 @@ function buildPage () {
|
||||
})
|
||||
}
|
||||
|
||||
// A fake wallet recording broadcast attempts; fails when failWith is set.
|
||||
function fakeWallet () {
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' },
|
||||
utxos: [{ txid: 'utxo-fee' }],
|
||||
getUtxos: async function () { return this.utxos },
|
||||
sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) {
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'prop-txid'
|
||||
}
|
||||
}
|
||||
return wallet
|
||||
}
|
||||
|
||||
function fakeFeed () {
|
||||
const posts = []
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
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' }
|
||||
)
|
||||
function buildBroadcastPage ({ wallet, navigations }) {
|
||||
return new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed: fakeFeed() }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
}
|
||||
|
||||
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' }
|
||||
)
|
||||
registerBehaviorProperties({
|
||||
Module: MemoPost,
|
||||
MAX,
|
||||
label: 'memo',
|
||||
rng,
|
||||
measure: (input) => input.length,
|
||||
buildPage,
|
||||
buildBroadcastPage
|
||||
})
|
||||
|
||||
test('menu link registration is idempotent', async () => {
|
||||
@@ -129,27 +65,3 @@ test('menu link registration is idempotent', async () => {
|
||||
{ label: 'menu link idempotence' }
|
||||
)
|
||||
})
|
||||
|
||||
test('a broadcast failure surfaces the error and never navigates', async () => {
|
||||
await forAll(
|
||||
(i) => ({ message: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }),
|
||||
({ message, failWith }) => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.failWith = failWith
|
||||
const navigations = []
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed: fakeFeed() }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput(message)
|
||||
|
||||
return page.submit().then((result) => {
|
||||
if (result.ok) return false
|
||||
if (page.submitError !== 'broadcast') return false
|
||||
if (!page.broadcastError || !page.broadcastError.includes('boom')) return false
|
||||
return navigations.length === 0
|
||||
})
|
||||
},
|
||||
{ label: 'broadcast failure does not navigate' }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Property tests for the Set Name behavior slice.
|
||||
|
||||
These assert useful invariants that unit tests cover only at a few fixed
|
||||
points. The validation, counter-conservation, setInput round-trip, and
|
||||
broadcast-failure invariants are shared with the Memo post slice and live in
|
||||
behavior-helpers.js; this file supplies the Set Name specifics.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const { seededRandom } = require('./harness')
|
||||
const { registerBehaviorProperties } = require('./behavior-helpers')
|
||||
const { fakeProfiles } = require('../helpers/fake-profiles')
|
||||
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const SetNamePage = require('../../src/services/set-name-page')
|
||||
|
||||
const MAX = MemoSetName.MAX_NAME_BYTES // 77
|
||||
|
||||
const rng = seededRandom(20260827)
|
||||
|
||||
function buildPage () {
|
||||
return new SetNamePage({
|
||||
memoSetName: new MemoSetName({}),
|
||||
navigate: () => {}
|
||||
})
|
||||
}
|
||||
|
||||
function buildBroadcastPage ({ wallet, navigations }) {
|
||||
return new SetNamePage({
|
||||
memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
}
|
||||
|
||||
registerBehaviorProperties({
|
||||
Module: MemoSetName,
|
||||
MAX,
|
||||
label: 'set-name',
|
||||
rng,
|
||||
measure: (input) => Buffer.byteLength(input, 'utf8'),
|
||||
buildPage,
|
||||
buildBroadcastPage
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Unit tests for the Account Page behavior slice (src/services/account-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/set-name.feature:
|
||||
- the account page shows the authenticated user's display name.
|
||||
- the account page exposes a Set Name button.
|
||||
- clicking the Set Name button navigates to /memo/set-name.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
|
||||
const ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
function fakeWallet (cashAddress = ADDRESS) {
|
||||
return { walletInfo: { cashAddress } }
|
||||
}
|
||||
|
||||
function fakeProfiles (initial = {}) {
|
||||
const names = { ...initial }
|
||||
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
|
||||
}
|
||||
|
||||
function build (deps = {}) {
|
||||
const wallet = deps.wallet !== undefined ? deps.wallet : fakeWallet()
|
||||
const profiles = deps.profiles !== undefined ? deps.profiles : fakeProfiles()
|
||||
const navigations = []
|
||||
const page = new AccountPage({
|
||||
wallet,
|
||||
profiles,
|
||||
navigate: (path) => navigations.push(path)
|
||||
})
|
||||
return { page, navigations, profiles }
|
||||
}
|
||||
|
||||
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
|
||||
assert.equal(AccountPage.SET_NAME_PATH, '/memo/set-name')
|
||||
assert.equal(AccountPage.ACCOUNT_PATH, '/account')
|
||||
})
|
||||
|
||||
test('the account page shows a Set Name button', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.hasSetNameButton(), true)
|
||||
})
|
||||
|
||||
test('clicking the Set Name button navigates to /memo/set-name', () => {
|
||||
const { page, navigations } = build()
|
||||
page.clickSetName()
|
||||
assert.deepEqual(navigations, ['/memo/set-name'])
|
||||
})
|
||||
|
||||
test('the account page shows the stored name for the authenticated address', () => {
|
||||
const profiles = fakeProfiles({ [ADDRESS]: 'trout' })
|
||||
const { page } = build({ profiles })
|
||||
assert.equal(page.getName(), 'trout')
|
||||
})
|
||||
|
||||
test('the account page returns null when no name is stored', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
|
||||
test('the account page returns null when no wallet is present', () => {
|
||||
const { page } = build({ wallet: null })
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
|
||||
test('the account page returns null when no profile store is present', () => {
|
||||
const { page } = build({ profiles: null })
|
||||
assert.equal(page.getName(), null)
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
|
||||
// Register the MemoAction tests shared by the Memo post and Set Name slices.
|
||||
// Both slices broadcast a value through a wallet and reflect the result on an
|
||||
// injected store, so the maximum-length and over-length behaviors are
|
||||
// identical; only the slice-specific pieces differ. `cfg` supplies:
|
||||
// Action - the action class (MemoPost or MemoSetName)
|
||||
// method - the broadcast method name ('post' or 'setName')
|
||||
// MAX - the length limit
|
||||
// lengthCode - the length error code
|
||||
// validationCode - the validation error code
|
||||
// label - a short label for test names
|
||||
// storeKey - the action's store dependency key ('feed' or 'profiles')
|
||||
// storeFactory - () => a fresh store
|
||||
// assertStoreEmpty - (store, wallet) => asserts the store was not updated
|
||||
// assertBroadcastMsg - (broadcast, value) => asserts the broadcast message
|
||||
// byteBased - true when the slice counts bytes (registers multi-byte tests)
|
||||
// extraArgs - extra arguments passed to the broadcast method after the value
|
||||
function registerMemoActionTests (cfg) {
|
||||
const { Action, method, MAX, lengthCode, validationCode, label, storeKey, storeFactory, assertStoreEmpty, assertBroadcastMsg, byteBased = false, extraArgs = [] } = cfg
|
||||
const checkBroadcastMsg = assertBroadcastMsg || ((broadcast, value) => assert.equal(broadcast.msg, value))
|
||||
|
||||
test(`${label} at the maximum length (${MAX}) is accepted`, async () => {
|
||||
const wallet = fakeWallet()
|
||||
const action = new Action({ wallet })
|
||||
|
||||
const value = 'x'.repeat(MAX)
|
||||
const txid = await action[method](value, ...extraArgs)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
checkBroadcastMsg(wallet.broadcasts[0], value)
|
||||
})
|
||||
|
||||
test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => {
|
||||
const wallet = fakeWallet()
|
||||
const store = storeFactory()
|
||||
const action = new Action({ wallet, [storeKey]: store })
|
||||
|
||||
await assert.rejects(
|
||||
action[method]('y'.repeat(MAX + 1), ...extraArgs),
|
||||
(err) => err.code === lengthCode
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assertStoreEmpty(store, wallet)
|
||||
})
|
||||
|
||||
test(`${label} that is whitespace-only or non-string throws a validation error and broadcasts nothing`, async () => {
|
||||
for (const invalid of [' ', 42]) {
|
||||
const wallet = fakeWallet()
|
||||
const action = new Action({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
action[method](invalid, ...extraArgs),
|
||||
(err) => err.code === validationCode
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test(`${label} that is empty throws a validation error and broadcasts nothing`, async () => {
|
||||
const wallet = fakeWallet()
|
||||
const store = storeFactory()
|
||||
const action = new Action({ wallet, [storeKey]: store })
|
||||
|
||||
await assert.rejects(
|
||||
action[method]('', ...extraArgs),
|
||||
(err) => err.code === validationCode
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assertStoreEmpty(store, wallet)
|
||||
})
|
||||
|
||||
if (byteBased) {
|
||||
test(`${label} with multi-byte characters at the byte limit is accepted`, async () => {
|
||||
const wallet = fakeWallet()
|
||||
const action = new Action({ wallet })
|
||||
|
||||
// 'é' encodes to 2 UTF-8 bytes, so floor(MAX/2) characters reach the limit.
|
||||
const count = Math.floor(MAX / 2)
|
||||
const value = 'é'.repeat(count)
|
||||
assert.equal(Buffer.byteLength(value, 'utf8'), count * 2)
|
||||
const txid = await action[method](value, ...extraArgs)
|
||||
assert.equal(txid, 'fake-txid')
|
||||
})
|
||||
|
||||
test(`${label} with multi-byte characters that exceed the byte limit throws a length error`, async () => {
|
||||
const wallet = fakeWallet()
|
||||
const action = new Action({ wallet })
|
||||
|
||||
const count = Math.floor(MAX / 2) + 1
|
||||
const value = 'é'.repeat(count)
|
||||
assert.ok(Buffer.byteLength(value, 'utf8') > MAX)
|
||||
|
||||
await assert.rejects(
|
||||
action[method](value, ...extraArgs),
|
||||
(err) => err.code === lengthCode
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { registerMemoActionTests }
|
||||
+14
-70
@@ -15,23 +15,8 @@ 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
|
||||
}
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
const { registerMemoActionTests } = require('./memo-action-helpers')
|
||||
|
||||
// A fake feed that records posts added to the recent posts feed.
|
||||
function fakeFeed () {
|
||||
@@ -39,6 +24,18 @@ function fakeFeed () {
|
||||
return { posts, addPost: (p) => posts.push(p) }
|
||||
}
|
||||
|
||||
registerMemoActionTests({
|
||||
Action: MemoPost,
|
||||
method: 'post',
|
||||
MAX: 217,
|
||||
lengthCode: 'memo_length',
|
||||
validationCode: 'memo_validation',
|
||||
label: 'posting a memo',
|
||||
storeKey: 'feed',
|
||||
storeFactory: fakeFeed,
|
||||
assertStoreEmpty: (feed) => assert.equal(feed.posts.length, 0)
|
||||
})
|
||||
|
||||
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
|
||||
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
|
||||
})
|
||||
@@ -55,10 +52,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||
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)
|
||||
@@ -66,55 +59,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
Unit tests for the Memo reply behavior slice (src/services/memo-reply.js).
|
||||
|
||||
These tests express the observable behavior described by
|
||||
specs/reply-memo.feature:
|
||||
- a valid reply broadcasts an OP_RETURN transaction carrying the Memo reply
|
||||
prefix (0x6d03), the parent txid bytes, and the message text; the thread
|
||||
reflects the new reply.
|
||||
- an empty reply is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long reply is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
const { registerMemoActionTests } = require('./memo-action-helpers')
|
||||
|
||||
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
|
||||
// A fake thread that records replies added to the thread view.
|
||||
function fakeThread (rootTxid = PARENT_TXID) {
|
||||
const replies = []
|
||||
return {
|
||||
rootTxid,
|
||||
replies,
|
||||
addReply: (r) => replies.push(r)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the reply text from a raw Uint8Array payload (skipping the 32-byte parent txid).
|
||||
function decodeReplyText (raw) {
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(raw.slice(32))
|
||||
}
|
||||
|
||||
// Decode the parent txid from a raw Uint8Array payload.
|
||||
function decodeParentTxid (raw) {
|
||||
return Buffer.from(raw.slice(0, 32)).toString('hex')
|
||||
}
|
||||
|
||||
registerMemoActionTests({
|
||||
Action: MemoReply,
|
||||
method: 'reply',
|
||||
MAX: 184,
|
||||
lengthCode: 'reply_length',
|
||||
validationCode: 'reply_validation',
|
||||
label: 'a reply',
|
||||
storeKey: 'thread',
|
||||
storeFactory: fakeThread,
|
||||
assertStoreEmpty: (thread) => assert.equal(thread.replies.length, 0),
|
||||
assertBroadcastMsg: (broadcast, value) => assert.equal(decodeReplyText(broadcast.msg), value),
|
||||
byteBased: true,
|
||||
extraArgs: [PARENT_TXID]
|
||||
})
|
||||
|
||||
test('MEMO_REPLY_PREFIX is the Memo reply action 0x6d03', () => {
|
||||
assert.equal(MemoReply.MEMO_REPLY_PREFIX, '6d03')
|
||||
})
|
||||
|
||||
test('replying with a valid message broadcasts an OP_RETURN with the Memo reply prefix and payload', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
|
||||
const txid = await memoReply.reply('hello memo', PARENT_TXID)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d03')
|
||||
assert.ok(b.msg instanceof Uint8Array)
|
||||
assert.equal(decodeParentTxid(b.msg), PARENT_TXID)
|
||||
assert.equal(decodeReplyText(b.msg), 'hello memo')
|
||||
|
||||
// The thread reflects the new reply from this address with this text.
|
||||
assert.equal(thread.replies.length, 1)
|
||||
assert.equal(thread.replies[0].text, 'hello memo')
|
||||
assert.equal(thread.replies[0].address, wallet.walletInfo.cashAddress)
|
||||
assert.equal(thread.replies[0].parentTxid, PARENT_TXID)
|
||||
})
|
||||
|
||||
test('replying without a wallet reports a missing-wallet error', async () => {
|
||||
const memoReply = new MemoReply({})
|
||||
await assert.rejects(
|
||||
memoReply.reply('hello memo', PARENT_TXID),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('replying with an invalid parent txid reports a clear error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoReply.reply('hello memo', 'not-a-txid'),
|
||||
(err) => /txid/i.test(err.message)
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('replying with a wrong-length but valid-hex parent txid is rejected', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
// 10 hex characters are valid hex but not the required 64-character txid.
|
||||
await assert.rejects(
|
||||
memoReply.reply('hello memo', 'a'.repeat(10)),
|
||||
(err) => /64-character hex/i.test(err.message)
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Unit tests for the Memo set-name behavior slice (src/services/memo-set-name.js).
|
||||
|
||||
These tests express the observable behavior described by specs/set-name.feature:
|
||||
- a valid name broadcasts an OP_RETURN transaction carrying the Memo set-name
|
||||
prefix (0x6d01) and the name text, and the profile store reflects the new
|
||||
name.
|
||||
- an empty name is rejected with a validation error and nothing is broadcast.
|
||||
- an over-long name is rejected with a length error and nothing is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
const { fakeProfiles } = require('../helpers/fake-profiles')
|
||||
const { registerMemoActionTests } = require('./memo-action-helpers')
|
||||
|
||||
registerMemoActionTests({
|
||||
Action: MemoSetName,
|
||||
method: 'setName',
|
||||
MAX: 77,
|
||||
lengthCode: 'name_length',
|
||||
validationCode: 'name_validation',
|
||||
label: 'setting a name',
|
||||
storeKey: 'profiles',
|
||||
storeFactory: fakeProfiles,
|
||||
assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null),
|
||||
byteBased: true
|
||||
})
|
||||
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
|
||||
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
|
||||
})
|
||||
|
||||
test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix and name', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
const txid = await memoSetName.setName('trout')
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d01')
|
||||
assert.equal(b.msg, 'trout')
|
||||
|
||||
// The profile store reflects the new name for this address.
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
|
||||
})
|
||||
|
||||
test('setting an empty name throws a validation error and broadcasts nothing', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName(''),
|
||||
(err) => err.code === 'name_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
})
|
||||
|
||||
test('setting a name without a wallet reports a missing-wallet error', async () => {
|
||||
const memoSetName = new MemoSetName({})
|
||||
await assert.rejects(
|
||||
memoSetName.setName('trout'),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('a failed broadcast does not update the profile store', async () => {
|
||||
const wallet = fakeWallet()
|
||||
wallet.sendOpReturn = async () => { throw new Error('broadcast failure') }
|
||||
const profiles = fakeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
|
||||
await assert.rejects(
|
||||
memoSetName.setName('trout'),
|
||||
(err) => /broadcast failure/i.test(err.message)
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
|
||||
})
|
||||
+31
-130
@@ -17,41 +17,29 @@ const assert = require('node:assert/strict')
|
||||
|
||||
const MemoPost = require('../../src/services/memo-post')
|
||||
const NewPostPage = require('../../src/services/new-post')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers')
|
||||
const { buildPage } = require('./page-build-helpers')
|
||||
|
||||
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 })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
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 buildPage({
|
||||
Page: NewPostPage,
|
||||
Action: MemoPost,
|
||||
actionKey: 'memoPost',
|
||||
storeKey: 'feed',
|
||||
storeFactory: fakeFeed
|
||||
})
|
||||
return { wallet, feed, memoPost, page, navigations }
|
||||
}
|
||||
|
||||
function buildBarePage (navigations) {
|
||||
return new NewPostPage({ navigate: (p) => navigations.push(p) })
|
||||
}
|
||||
|
||||
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
|
||||
@@ -82,54 +70,19 @@ test('the character counter reaches zero at the memo limit', () => {
|
||||
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, [])
|
||||
registerPageSubmitTests({
|
||||
buildPage: build,
|
||||
verb: 'posting',
|
||||
label: 'memo',
|
||||
busyFlag: 'posting',
|
||||
prefix: '6d02',
|
||||
validationCode: 'memo_validation',
|
||||
lengthCode: 'memo_length',
|
||||
MAX,
|
||||
successPath: '/posts/recent',
|
||||
assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
|
||||
assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
|
||||
assertStoreEmpty: (store) => assert.equal(store.posts.length, 0)
|
||||
})
|
||||
|
||||
test('the new post page starts idle (not posting)', () => {
|
||||
@@ -137,63 +90,11 @@ test('the new post page starts idle (not posting)', () => {
|
||||
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((resolve) => setImmediate(resolve))
|
||||
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, [])
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces the real error and does not navigate', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const feed = fakeFeed()
|
||||
wallet.failWith = 'BCH UTXO list is empty'
|
||||
const navigations = []
|
||||
const page = new NewPostPage({
|
||||
memoPost: new MemoPost({ wallet, feed }),
|
||||
navigate: (p) => navigations.push(p)
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /BCH UTXO list is empty/)
|
||||
// The broadcast was attempted (recorded) before it failed.
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d02')
|
||||
// The user stays on the page.
|
||||
assert.deepEqual(navigations, [])
|
||||
registerPageControllerTests({
|
||||
buildPage: build,
|
||||
buildBarePage,
|
||||
busyFlag: 'posting',
|
||||
prefix: '6d02'
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces a different real error message', async () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
Unit tests for the optimistic reply object builder
|
||||
(src/services/optimistic-reply.js).
|
||||
|
||||
The reply form renders an optimistic reply immediately after a successful
|
||||
broadcast, before the thread is refreshed from the network. This module
|
||||
shapes that reply object so the shape is covered by unit tests and the
|
||||
React form stays a thin adapter.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { buildOptimisticReply } = require('../../src/services/optimistic-reply')
|
||||
|
||||
const TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
const ADDR = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
test('builds a reply with a zero reply count and no child replies', () => {
|
||||
const reply = buildOptimisticReply({
|
||||
txid: TXID,
|
||||
addr: ADDR,
|
||||
text: 'hello',
|
||||
seen: 1234,
|
||||
blockHeight: 800000,
|
||||
displayName: null
|
||||
})
|
||||
|
||||
assert.equal(reply.txid, TXID)
|
||||
assert.equal(reply.addr, ADDR)
|
||||
assert.equal(reply.text, 'hello')
|
||||
assert.equal(reply.seen, 1234)
|
||||
assert.equal(reply.blockHeight, 800000)
|
||||
assert.equal(reply.replyCount, 0)
|
||||
assert.deepEqual(reply.replies, [])
|
||||
assert.equal(reply.profile, undefined)
|
||||
})
|
||||
|
||||
test('attaches a profile when a display name is present', () => {
|
||||
const reply = buildOptimisticReply({
|
||||
txid: TXID,
|
||||
addr: ADDR,
|
||||
text: 'hello',
|
||||
seen: 1234,
|
||||
blockHeight: undefined,
|
||||
displayName: 'Trout'
|
||||
})
|
||||
|
||||
assert.deepEqual(reply.profile, { name: 'Trout' })
|
||||
assert.equal(reply.blockHeight, undefined)
|
||||
})
|
||||
|
||||
test('preserves an absent block height', () => {
|
||||
const reply = buildOptimisticReply({
|
||||
txid: TXID,
|
||||
addr: ADDR,
|
||||
text: 'hello',
|
||||
seen: 1234,
|
||||
blockHeight: null,
|
||||
displayName: null
|
||||
})
|
||||
|
||||
assert.equal(reply.blockHeight, null)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict'
|
||||
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
|
||||
// Build a page controller wired to a working action and a navigation recorder.
|
||||
// Both the New Post and Set Name pages extend PageController and take a single
|
||||
// action dependency plus a navigate callback, so the wiring is identical; only
|
||||
// the page-specific pieces differ. `cfg` supplies:
|
||||
// Page - the page controller class (NewPostPage or SetNamePage)
|
||||
// Action - the action class (MemoPost or MemoSetName)
|
||||
// actionKey - the page's action dependency key ('memoPost' or 'memoSetName')
|
||||
// storeKey - the action's store dependency key ('feed' or 'profiles')
|
||||
// storeFactory - () => a fresh store
|
||||
// pageDeps - extra dependencies passed to the page constructor
|
||||
function buildPage ({ Page, Action, actionKey, storeKey, storeFactory, pageDeps = {} }) {
|
||||
const wallet = fakeWallet()
|
||||
const store = storeFactory()
|
||||
const action = new Action({ wallet, [storeKey]: store })
|
||||
const navigations = []
|
||||
const page = new Page({
|
||||
[actionKey]: action,
|
||||
navigate: (path) => navigations.push(path),
|
||||
...pageDeps
|
||||
})
|
||||
return { wallet, store, action, page, navigations }
|
||||
}
|
||||
|
||||
module.exports = { buildPage }
|
||||
@@ -0,0 +1,131 @@
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
// Register the page-controller tests shared by the New Post and Set Name pages.
|
||||
// Both pages extend PageController, so the in-flight flag, missing-handler, and
|
||||
// broadcast-failure behaviors are identical; only the page-specific pieces
|
||||
// differ. `cfg` supplies:
|
||||
// buildPage - () => ({ wallet, page, navigations }) with a working handler
|
||||
// buildBarePage - (navigations) => a page with no action handler
|
||||
// busyFlag - the page's in-flight flag name ('posting' or 'settingName')
|
||||
// prefix - the broadcast prefix ('6d02' or '6d01')
|
||||
function registerPageControllerTests (cfg) {
|
||||
const { buildPage, buildBarePage, busyFlag, prefix } = cfg
|
||||
|
||||
test(`${busyFlag} is true while a submit is in flight and false once it settles`, async () => {
|
||||
const { wallet, page } = buildPage()
|
||||
|
||||
// Defer the broadcast so we can observe the in-flight state.
|
||||
let resolveSend
|
||||
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||
page.setInput('hello')
|
||||
|
||||
assert.equal(page[busyFlag], false)
|
||||
const pending = page.submit()
|
||||
assert.equal(page[busyFlag], true)
|
||||
|
||||
// Yield until the async chain reaches the deferred sendOpReturn call.
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(typeof resolveSend, 'function')
|
||||
resolveSend('in-flight-txid')
|
||||
await pending
|
||||
assert.equal(page[busyFlag], false)
|
||||
})
|
||||
|
||||
test('submitting without a handler reports an error and does not navigate', async () => {
|
||||
const navigations = []
|
||||
const page = buildBarePage(navigations)
|
||||
page.setInput('hello')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces the real error and does not navigate', async () => {
|
||||
const { wallet, page, navigations } = buildPage()
|
||||
wallet.failWith = 'BCH UTXO list is empty'
|
||||
page.setInput('hello')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /BCH UTXO list is empty/)
|
||||
// The broadcast was attempted (recorded) before it failed.
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, prefix)
|
||||
// The user stays on the page.
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
}
|
||||
|
||||
// Register the page-submit tests shared by the New Post and Reply Thread pages.
|
||||
// Both pages extend PageController and submit a single action, so the valid,
|
||||
// empty, and over-long submit behaviors are identical; only the page-specific
|
||||
// pieces differ. `cfg` supplies:
|
||||
// buildPage - () => ({ wallet, store, page, navigations })
|
||||
// verb - the action verb for test names ('posting' or 'submitting')
|
||||
// label - the noun for test names ('memo' or 'reply')
|
||||
// busyFlag - the page's in-flight flag name ('posting' or 'replying')
|
||||
// prefix - the broadcast prefix ('6d02' or '6d03')
|
||||
// validationCode - the validation error code
|
||||
// lengthCode - the length error code
|
||||
// MAX - the length limit
|
||||
// successPath - the navigation path on success, or null for no navigation
|
||||
// assertBroadcastMsg - (broadcast) => asserts the broadcast message (optional)
|
||||
// assertStore - (store, wallet) => asserts the store reflects the value
|
||||
// assertStoreEmpty - (store, wallet) => asserts the store was not updated
|
||||
function registerPageSubmitTests (cfg) {
|
||||
const { buildPage, verb, label, busyFlag, prefix, validationCode, lengthCode, MAX, successPath, assertBroadcastMsg, assertStore, assertStoreEmpty } = cfg
|
||||
|
||||
test(`${verb} a valid ${label} broadcasts the ${prefix} prefix and reflects it`, async () => {
|
||||
const { wallet, store, page, navigations } = buildPage()
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page[busyFlag], false)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, prefix)
|
||||
if (assertBroadcastMsg) assertBroadcastMsg(wallet.broadcasts[0])
|
||||
assert.deepEqual(navigations, successPath ? [successPath] : [])
|
||||
assertStore(store, wallet)
|
||||
})
|
||||
|
||||
test(`${verb} an empty ${label} is rejected with a validation error and nothing is broadcast`, async () => {
|
||||
const { wallet, store, page, navigations } = buildPage()
|
||||
page.setInput('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, validationCode)
|
||||
assert.equal(page.submitError, validationCode)
|
||||
assert.equal(page[busyFlag], false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assertStoreEmpty(store, wallet)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test(`${verb} an over-long ${label} is rejected with a length error and nothing is broadcast`, async () => {
|
||||
const { wallet, store, page, navigations } = buildPage()
|
||||
page.setInput('y'.repeat(MAX + 1))
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, lengthCode)
|
||||
assert.equal(page.submitError, lengthCode)
|
||||
assert.equal(page[busyFlag], false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assertStoreEmpty(store, wallet)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerPageControllerTests, registerPageSubmitTests }
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Unit tests for the session profile store (src/services/profiles.js).
|
||||
|
||||
The store indexes display names by BCH cash address so that pages can read
|
||||
a name immediately after it is broadcast.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const Profiles = require('../../src/services/profiles')
|
||||
|
||||
test('returns null when no name has been set for an address', () => {
|
||||
const profiles = new Profiles()
|
||||
assert.equal(profiles.getName('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'), null)
|
||||
})
|
||||
|
||||
test('stores and retrieves a name by address', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
profiles.setName(addr, 'trout')
|
||||
assert.equal(profiles.getName(addr), 'trout')
|
||||
})
|
||||
|
||||
test('updating a name overwrites the previous value', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
profiles.setName(addr, 'trout')
|
||||
profiles.setName(addr, 'salmon')
|
||||
assert.equal(profiles.getName(addr), 'salmon')
|
||||
})
|
||||
|
||||
test('different addresses keep independent names', () => {
|
||||
const profiles = new Profiles()
|
||||
const addr1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const addr2 = 'bitcoincash:qq0ktdlgekdszmxhmg7y6a90t9dpj6p0pg3gctn9e'
|
||||
profiles.setName(addr1, 'trout')
|
||||
profiles.setName(addr2, 'salmon')
|
||||
assert.equal(profiles.getName(addr1), 'trout')
|
||||
assert.equal(profiles.getName(addr2), 'salmon')
|
||||
})
|
||||
|
||||
test('ignores setName for a missing address', () => {
|
||||
const profiles = new Profiles()
|
||||
profiles.setName(null, 'trout')
|
||||
assert.equal(profiles.getName(null), null)
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Unit tests for the Reply Thread Page behavior slice (src/services/reply-thread-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/reply-memo.feature:
|
||||
- replying with a valid message broadcasts an OP_RETURN with the Memo reply
|
||||
prefix and reflects the reply in the thread.
|
||||
- an empty reply is rejected with a validation error; nothing is broadcast.
|
||||
- an over-long reply is rejected with a length error; nothing is broadcast.
|
||||
- the byte counter counts down from the reply limit.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
const ReplyThreadPage = require('../../src/services/reply-thread-page')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers')
|
||||
const { buildPage } = require('./page-build-helpers')
|
||||
|
||||
const MAX = MemoReply.MAX_REPLY_BYTES // 184
|
||||
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
|
||||
function fakeThread (rootTxid = PARENT_TXID) {
|
||||
const replies = []
|
||||
return { rootTxid, replies, addReply: (r) => replies.push(r) }
|
||||
}
|
||||
|
||||
function build () {
|
||||
return buildPage({
|
||||
Page: ReplyThreadPage,
|
||||
Action: MemoReply,
|
||||
actionKey: 'memoReply',
|
||||
storeKey: 'thread',
|
||||
storeFactory: fakeThread,
|
||||
pageDeps: { parentTxid: PARENT_TXID }
|
||||
})
|
||||
}
|
||||
|
||||
function buildBarePage (navigations) {
|
||||
return new ReplyThreadPage({ navigate: (p) => navigations.push(p) })
|
||||
}
|
||||
|
||||
test('REPLY_THREAD_PATH constant', () => {
|
||||
assert.equal(ReplyThreadPage.REPLY_THREAD_PATH, '/posts/thread')
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the reply limit for an empty reply', () => {
|
||||
const { page } = build()
|
||||
page.setInput('')
|
||||
assert.equal(page.remainingCount(), MAX)
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the reply limit for a short reply', () => {
|
||||
const { page } = build()
|
||||
page.setInput('hello')
|
||||
assert.equal(page.remainingCount(), MAX - 5)
|
||||
})
|
||||
|
||||
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
|
||||
const { page } = build()
|
||||
page.setInput('é')
|
||||
assert.equal(page.remainingCount(), MAX - 2)
|
||||
})
|
||||
|
||||
test('the byte counter reaches zero at the reply byte limit', () => {
|
||||
const { page } = build()
|
||||
page.setInput('x'.repeat(MAX))
|
||||
assert.equal(page.remainingCount(), 0)
|
||||
})
|
||||
|
||||
registerPageSubmitTests({
|
||||
buildPage: build,
|
||||
verb: 'submitting',
|
||||
label: 'reply',
|
||||
busyFlag: 'replying',
|
||||
prefix: '6d03',
|
||||
validationCode: 'reply_validation',
|
||||
lengthCode: 'reply_length',
|
||||
MAX,
|
||||
successPath: null,
|
||||
assertStore: (store) => {
|
||||
assert.equal(store.replies[0].text, 'hello memo')
|
||||
assert.equal(store.replies[0].parentTxid, PARENT_TXID)
|
||||
},
|
||||
assertStoreEmpty: (store) => assert.equal(store.replies.length, 0)
|
||||
})
|
||||
|
||||
test('the reply page starts idle (not replying)', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.replying, false)
|
||||
})
|
||||
|
||||
registerPageControllerTests({
|
||||
buildPage: build,
|
||||
buildBarePage,
|
||||
busyFlag: 'replying',
|
||||
prefix: '6d03'
|
||||
})
|
||||
|
||||
test('replying to a nested reply uses the selected parent txid', async () => {
|
||||
const nestedTxid = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
||||
const { store, page } = build()
|
||||
page.setParent(nestedTxid)
|
||||
page.setInput('hello nested')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(store.replies[0].parentTxid, nestedTxid)
|
||||
assert.equal(store.replies[0].text, 'hello nested')
|
||||
})
|
||||
|
||||
test('the reply page navigates to a configured success path on success', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const thread = fakeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const navigations = []
|
||||
const page = new ReplyThreadPage({
|
||||
memoReply,
|
||||
navigate: (p) => navigations.push(p),
|
||||
successPath: '/custom-path',
|
||||
parentTxid: PARENT_TXID
|
||||
})
|
||||
page.setInput('hello memo')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.deepEqual(navigations, ['/custom-path'])
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
Unit tests for the Set Name Page behavior slice (src/services/set-name-page.js).
|
||||
|
||||
Expresses the observable behavior described by specs/set-name.feature:
|
||||
- setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix
|
||||
and navigates the user to the account page.
|
||||
- an empty name is rejected with a validation error; nothing is broadcast.
|
||||
- an over-long name is rejected with a length error; nothing is broadcast.
|
||||
- the byte counter counts down from the name limit.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const SetNamePage = require('../../src/services/set-name-page')
|
||||
const { fakeProfiles } = require('../helpers/fake-profiles')
|
||||
const { registerPageControllerTests } = require('./page-controller-helpers')
|
||||
const { buildPage } = require('./page-build-helpers')
|
||||
|
||||
const MAX = MemoSetName.MAX_NAME_BYTES // 77
|
||||
|
||||
function build () {
|
||||
return buildPage({
|
||||
Page: SetNamePage,
|
||||
Action: MemoSetName,
|
||||
actionKey: 'memoSetName',
|
||||
storeKey: 'profiles',
|
||||
storeFactory: fakeProfiles
|
||||
})
|
||||
}
|
||||
|
||||
function buildBarePage (navigations) {
|
||||
return new SetNamePage({ navigate: (p) => navigations.push(p) })
|
||||
}
|
||||
|
||||
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
|
||||
assert.equal(SetNamePage.SET_NAME_PATH, '/memo/set-name')
|
||||
assert.equal(SetNamePage.ACCOUNT_PATH, '/account')
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the name limit for an empty name', () => {
|
||||
const { page } = build()
|
||||
page.setInput('')
|
||||
assert.equal(page.remainingCount(), MAX)
|
||||
})
|
||||
|
||||
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
|
||||
const { page } = build()
|
||||
page.setInput('é')
|
||||
assert.equal(page.remainingCount(), MAX - 2)
|
||||
})
|
||||
|
||||
test('the byte counter reaches zero at the byte limit with multi-byte characters', () => {
|
||||
const { page } = build()
|
||||
page.setInput('é'.repeat(38))
|
||||
assert.equal(page.remainingCount(), 1)
|
||||
})
|
||||
|
||||
test('the byte counter counts down from the name limit for a short name', () => {
|
||||
const { page } = build()
|
||||
page.setInput('trout')
|
||||
assert.equal(page.remainingCount(), MAX - 5)
|
||||
})
|
||||
|
||||
test('the byte counter reaches zero at the name limit', () => {
|
||||
const { page } = build()
|
||||
page.setInput('x'.repeat(MAX))
|
||||
assert.equal(page.remainingCount(), 0)
|
||||
})
|
||||
|
||||
test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => {
|
||||
const { wallet, store, page, navigations } = build()
|
||||
page.setInput('trout')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d01')
|
||||
assert.equal(wallet.broadcasts[0].msg, 'trout')
|
||||
assert.deepEqual(navigations, ['/account'])
|
||||
assert.equal(store.getName(wallet.walletInfo.cashAddress), 'trout')
|
||||
})
|
||||
|
||||
test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => {
|
||||
const { wallet, store, page, navigations } = build()
|
||||
page.setInput('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'name_validation')
|
||||
assert.equal(page.submitError, 'name_validation')
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(store.getName(wallet.walletInfo.cashAddress), null)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => {
|
||||
const { wallet, store, page, navigations } = build()
|
||||
page.setInput('y'.repeat(MAX + 1))
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'name_length')
|
||||
assert.equal(page.submitError, 'name_length')
|
||||
assert.equal(page.settingName, false)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
assert.equal(store.getName(wallet.walletInfo.cashAddress), null)
|
||||
assert.deepEqual(navigations, [])
|
||||
})
|
||||
|
||||
test('the set name page starts idle (not setting name)', () => {
|
||||
const { page } = build()
|
||||
assert.equal(page.settingName, false)
|
||||
})
|
||||
|
||||
registerPageControllerTests({
|
||||
buildPage: build,
|
||||
buildBarePage,
|
||||
busyFlag: 'settingName',
|
||||
prefix: '6d01'
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Unit tests for the UTF-8 byte-length helper (src/services/utf8.js).
|
||||
|
||||
The helper must report the same UTF-8 byte length as Node's Buffer without
|
||||
depending on the Node-only `Buffer` global, so the browser build (which has
|
||||
no Buffer) can count bytes for the Memo set-name counter and length check.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { byteLength } = require('../../src/services/utf8')
|
||||
|
||||
test('byteLength matches Buffer.byteLength for ASCII text', () => {
|
||||
for (const s of ['', 'trout', 'a longer name with spaces', 'x'.repeat(77)]) {
|
||||
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
test('byteLength matches Buffer.byteLength for multi-byte characters', () => {
|
||||
for (const s of ['é', 'é'.repeat(38), '😀', '😀'.repeat(20), '日本語']) {
|
||||
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
|
||||
}
|
||||
})
|
||||
|
||||
test('byteLength counts UTF-8 bytes, not characters', () => {
|
||||
// 'é' is 1 character but 2 UTF-8 bytes; an emoji is 1 character but 4 bytes.
|
||||
assert.equal(byteLength('é'), 2)
|
||||
assert.equal(byteLength('😀'), 4)
|
||||
assert.equal(byteLength('a'), 1)
|
||||
})
|
||||
|
||||
test('byteLength coerces non-string input to a string', () => {
|
||||
assert.equal(byteLength(42), 2)
|
||||
assert.equal(byteLength(null), 4) // String(null) === 'null'
|
||||
})
|
||||
Reference in New Issue
Block a user