Merge branch 'swarmforge-refactorer' into swarmforge-architect

This commit is contained in:
Chris Troutner
2026-08-25 21:15:23 -07:00
20 changed files with 846 additions and 115 deletions
+159 -5
View File
@@ -2,27 +2,33 @@
Project step handlers for the psf-memo-client acceptance pipeline. Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to real project behavior These handlers connect Gherkin step text to real project behavior
(src/services/memo-post.js and src/services/new-post.js), driving them (src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js,
through small injected adapters (a fake wallet, a fake feed, and a fake src/services/reply-thread-page.js, src/services/memo-set-name.js, and
navigator) so the acceptance run is deterministic and offline. 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 Regex matching with placeholder-name capture is the default style: a single
handler pattern captures the placeholder name (e.g. <message>) and fetches handler pattern captures the placeholder name (e.g. <message>) and fetches
the example value from the scenario example store. the example value from the scenario example store.
The handlers serve both specs/post-memo.feature and specs/memo-new.feature, The handlers serve specs/post-memo.feature, specs/memo-new.feature,
whose wording differs but which share the same underlying Memo post behavior. 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' 'use strict'
const MemoPost = require('../../src/services/memo-post') const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-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 MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page') const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page') const AccountPage = require('../../src/services/account-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX 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 const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. // A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
@@ -63,15 +69,29 @@ function makeProfiles () {
} }
} }
// 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. // Fresh world/state object for a single scenario execution.
function createWorld () { function createWorld () {
const wallet = makeWallet('') const wallet = makeWallet('')
const feed = makeFeed() const feed = makeFeed()
const memoPost = new MemoPost({ wallet, feed }) const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const world = { const world = {
wallet, wallet,
feed, feed,
thread,
memoPost, memoPost,
memoReply,
currentPath: null, currentPath: null,
menuOpen: false menuOpen: false
} }
@@ -84,6 +104,13 @@ function createWorld () {
menuLinks: [] 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 // The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other. // a name set on one page is visible on the other.
const profiles = makeProfiles() const profiles = makeProfiles()
@@ -101,6 +128,14 @@ function createWorld () {
return world 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 }. // Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step). // run receives (match, exampleStore, world, step).
const handlers = [ const handlers = [
@@ -213,6 +248,63 @@ const handlers = [
await world.setNamePage.submit() await world.setNamePage.submit()
} }
}, },
{
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', name: 'click Set Name button',
pattern: /^I click the Set Name button$/, pattern: /^I click the Set Name button$/,
@@ -254,6 +346,68 @@ const handlers = [
} }
} }
}, },
{
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', 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_]+)>"$/, pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
+32 -7
View File
@@ -104,12 +104,20 @@ Saved (and updated) at `specs/feature-backlog.md`. memo protocol action bytes be
**Completed ✓ (merged to `feat1`):** **Completed ✓ (merged to `feat1`):**
- Post a Memo (`0x6d02`) — service + `/posts/new` page + broadcast fix. FULLY DONE. - 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:** **Tier P1 — Core social verbs (write + read) — do these next, in order:**
1. ✅ Post a Memo (`0x6d02`) — DONE 1. ✅ Post a Memo (`0x6d02`) — DONE
2. Set display name (`0x6d01`) — **NEXT** (breadcrumb: the feed already renders 2. Set display name (`0x6d01`) — DONE
display names; the write action is missing) 3. Reply to a Memo (`0x6d03`) — **NEXT** (thread already renders; add reply broadcast)
3. Reply to a Memo (`0x6d03`) — thread already renders; add reply broadcast - **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.
4. Like / tip a Memo (`0x6d04`) 4. Like / tip a Memo (`0x6d04`)
5. Set profile text / bio (`0x6d05`) 5. Set profile text / bio (`0x6d05`)
6. Set profile picture (`0x6d0a`) 6. Set profile picture (`0x6d0a`)
@@ -228,11 +236,24 @@ specing reply/like/follow.
(`Failed to broadcast: <msg>`). Keep that behavior in specs. (`Failed to broadcast: <msg>`). Keep that behavior in specs.
6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely 6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely
on user-provided behavior details and the protocol spec. on user-provided behavior details and the protocol spec.
7. **Byte vs char:** the 217 limit and the counter currently count characters 7. **Byte vs char:** the 217 post limit and its counter count characters (`input.length`,
(`input.length`, UTF-16), not bytes. The user is aware; multi-byte unicode may UTF-16), not bytes. The user is aware; multi-byte unicode may diverge. **Set Name
diverge. Ask/decide per feature. (`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 8. **Live backend for e2e:** `https://memo-api.fullstackcash.net/` (prod memo-db). The
user can provide BCH for real broadcasts. 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 +277,8 @@ At the end of each session, update this file:
- Mark features completed in the backlog (§5). - Mark features completed in the backlog (§5).
- Add any new gotchas to §10. - Add any new gotchas to §10.
- Note the current `feat1` HEAD commit. - 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: **Reply to a Memo, `0x6d03`**).
Current `display-name` HEAD: `230618d` (Set Name buffer fix merged).
Next feature: **Reply to a Memo, `0x6d03`** — spec decisions captured in §5; Gherkin
not yet written; awaiting user approval before handoff to coder.
+8 -3
View File
@@ -63,7 +63,7 @@ action plus its read/display surface. This is the recommended first development
| # | Feature | Memo action | Write | Read surface | | # | Feature | Memo action | Write | Read surface |
|---|---------|-------------|-------|--------------| |---|---------|-------------|-------|--------------|
| 1 | Post a Memo | `0x6d02` | Compose + `sendOpReturn` | Appears in recent feed & own profile after indexing | | 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 | | 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 | | 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 | | 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 ## Priority order within P1
1. **Post a Memo** — the primary verb; unblocks all others. 1. **Post a Memo** — the primary verb; unblocks all others. ✅ DONE
2. **Set display name** — makes the feed readable and gives identity. 2. **Set display name** — makes the feed readable and gives identity. ✅ DONE
3. **Reply to a Memo** — core conversation; extends the existing thread modal. 3. **Reply to a Memo** — core conversation; extends the existing thread modal.
- **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. 4. **Like a Memo** — social signal; needs like-count API.
5. **Set profile text** — bio for the profile page. 5. **Set profile text** — bio for the profile page.
6. **Set profile picture** — avatar for posts/profiles. 6. **Set profile picture** — avatar for posts/profiles.
+61
View File
@@ -0,0 +1,61 @@
# 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
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
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Scenario Outline: Reply to a Memo - 1 a valid reply to a post is broadcast and shown in the thread
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
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
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
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
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. |
+2 -1
View File
@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'
// Local libraries // Local libraries
import MemoSetName from '../../../services/memo-set-name' import MemoSetName from '../../../services/memo-set-name'
import SetNamePage from '../../../services/set-name-page' import SetNamePage from '../../../services/set-name-page'
import { byteLength } from '../../../services/utf8'
function SetName (props) { function SetName (props) {
const { appData } = props const { appData } = props
@@ -22,7 +23,7 @@ function SetName (props) {
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [settingName, setSettingName] = useState(false) const [settingName, setSettingName] = useState(false)
const remaining = maxBytes - Buffer.byteLength(input, 'utf8') const remaining = maxBytes - byteLength(input)
async function handleSubmit (event) { async function handleSubmit (event) {
event.preventDefault() event.preventDefault()
+112
View File
@@ -0,0 +1,112 @@
/*
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
+2 -1
View File
@@ -17,6 +17,7 @@
*/ */
const MemoAction = require('./memo-action') const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_SET_NAME_PREFIX = '6d01' const MEMO_SET_NAME_PREFIX = '6d01'
const MAX_NAME_BYTES = 77 const MAX_NAME_BYTES = 77
@@ -38,7 +39,7 @@ class MemoSetName extends MemoAction {
// A name is over-length when it exceeds the byte limit. // A name is over-length when it exceeds the byte limit.
isTooLong (name) { isTooLong (name) {
return Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES return byteLength(name) > MAX_NAME_BYTES
} }
// Compose and broadcast a Memo set-name transaction for the given name. // Compose and broadcast a Memo set-name transaction for the given name.
+3 -1
View File
@@ -33,7 +33,9 @@ class PageController {
try { try {
const txid = await this._perform(this.input) const txid = await this._perform(this.input)
this.navigate(this.successPath) if (this.successPath) {
this.navigate(this.successPath)
}
this._setBusy(false) this._setBusy(false)
return { ok: true, txid } return { ok: true, txid }
} catch (err) { } catch (err) {
+59
View File
@@ -0,0 +1,59 @@
/*
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
+2 -1
View File
@@ -15,6 +15,7 @@
const PageController = require('./page-controller') const PageController = require('./page-controller')
const MemoSetName = require('./memo-set-name') const MemoSetName = require('./memo-set-name')
const { byteLength } = require('./utf8')
const SET_NAME_PATH = '/memo/set-name' const SET_NAME_PATH = '/memo/set-name'
const ACCOUNT_PATH = '/account' const ACCOUNT_PATH = '/account'
@@ -30,7 +31,7 @@ class SetNamePage extends PageController {
// Bytes remaining before the name limit is reached. // Bytes remaining before the name limit is reached.
remainingCount () { remainingCount () {
return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8') return MemoSetName.MAX_NAME_BYTES - byteLength(this.input)
} }
// Set the in-flight setting-name flag. // Set the in-flight setting-name flag.
+15
View File
@@ -0,0 +1,15 @@
/*
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 }
+51 -5
View File
@@ -18,17 +18,21 @@ const { fakeWallet } = require('../helpers/fake-wallet')
// storeKey - the action's store dependency key ('feed' or 'profiles') // storeKey - the action's store dependency key ('feed' or 'profiles')
// storeFactory - () => a fresh store // storeFactory - () => a fresh store
// assertStoreEmpty - (store, wallet) => asserts the store was not updated // 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) { function registerMemoActionTests (cfg) {
const { Action, method, MAX, lengthCode, validationCode, label, storeKey, storeFactory, assertStoreEmpty } = 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 () => { test(`${label} at the maximum length (${MAX}) is accepted`, async () => {
const wallet = fakeWallet() const wallet = fakeWallet()
const action = new Action({ wallet }) const action = new Action({ wallet })
const value = 'x'.repeat(MAX) const value = 'x'.repeat(MAX)
const txid = await action[method](value) const txid = await action[method](value, ...extraArgs)
assert.equal(txid, 'fake-txid') assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts[0].msg, value) checkBroadcastMsg(wallet.broadcasts[0], value)
}) })
test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => { test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => {
@@ -37,7 +41,7 @@ function registerMemoActionTests (cfg) {
const action = new Action({ wallet, [storeKey]: store }) const action = new Action({ wallet, [storeKey]: store })
await assert.rejects( await assert.rejects(
action[method]('y'.repeat(MAX + 1)), action[method]('y'.repeat(MAX + 1), ...extraArgs),
(err) => err.code === lengthCode (err) => err.code === lengthCode
) )
assert.equal(wallet.broadcasts.length, 0) assert.equal(wallet.broadcasts.length, 0)
@@ -50,12 +54,54 @@ function registerMemoActionTests (cfg) {
const action = new Action({ wallet }) const action = new Action({ wallet })
await assert.rejects( await assert.rejects(
action[method](invalid), action[method](invalid, ...extraArgs),
(err) => err.code === validationCode (err) => err.code === validationCode
) )
assert.equal(wallet.broadcasts.length, 0) 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 } module.exports = { registerMemoActionTests }
-13
View File
@@ -59,19 +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) assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress)
}) })
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 without a wallet reports a missing-wallet error', async () => { test('posting without a wallet reports a missing-wallet error', async () => {
const memoPost = new MemoPost({}) const memoPost = new MemoPost({})
await assert.rejects( await assert.rejects(
+103
View File
@@ -0,0 +1,103 @@
/*
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)
})
+2 -26
View File
@@ -28,7 +28,8 @@ registerMemoActionTests({
label: 'setting a name', label: 'setting a name',
storeKey: 'profiles', storeKey: 'profiles',
storeFactory: fakeProfiles, storeFactory: fakeProfiles,
assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) 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', () => { test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01') assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
@@ -51,31 +52,6 @@ test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout') assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
}) })
test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
// 38 'é' characters are 76 bytes in UTF-8.
const name = 'é'.repeat(38)
assert.equal(Buffer.byteLength(name, 'utf8'), 76)
const txid = await memoSetName.setName(name)
assert.equal(txid, 'fake-txid')
})
test('setting an over-long name in bytes (78) throws a length error even when char count is lower', async () => {
const wallet = fakeWallet()
const memoSetName = new MemoSetName({ wallet })
// 40 'é' characters are 80 bytes, exceeding the 77-byte limit.
const name = 'é'.repeat(40)
assert.ok(Buffer.byteLength(name, 'utf8') > 77)
await assert.rejects(
memoSetName.setName(name),
(err) => err.code === 'name_length'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('setting an empty name throws a validation error and broadcasts nothing', async () => { test('setting an empty name throws a validation error and broadcasts nothing', async () => {
const wallet = fakeWallet() const wallet = fakeWallet()
const profiles = fakeProfiles() const profiles = fakeProfiles()
+14 -49
View File
@@ -18,7 +18,7 @@ const assert = require('node:assert/strict')
const MemoPost = require('../../src/services/memo-post') const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post') const NewPostPage = require('../../src/services/new-post')
const { fakeWallet } = require('../helpers/fake-wallet') const { fakeWallet } = require('../helpers/fake-wallet')
const { registerPageControllerTests } = require('./page-controller-helpers') const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers')
const { buildPage } = require('./page-build-helpers') const { buildPage } = require('./page-build-helpers')
const MAX = MemoPost.MAX_MEMO_CHARS // 217 const MAX = MemoPost.MAX_MEMO_CHARS // 217
@@ -70,54 +70,19 @@ test('the character counter reaches zero at the memo limit', () => {
assert.equal(page.remainingCount(), 0) assert.equal(page.remainingCount(), 0)
}) })
test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => { registerPageSubmitTests({
const { wallet, store, page, navigations } = build() buildPage: build,
page.setInput('hello memo') verb: 'posting',
label: 'memo',
const result = await page.submit() busyFlag: 'posting',
prefix: '6d02',
assert.equal(result.ok, true) validationCode: 'memo_validation',
// The page returns to an idle (not posting) state after success. lengthCode: 'memo_length',
assert.equal(page.posting, false) MAX,
// Broadcast happened with the Memo post prefix and the exact message. successPath: '/posts/recent',
assert.equal(wallet.broadcasts.length, 1) assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
assert.equal(wallet.broadcasts[0].prefix, '6d02') assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
assert.equal(wallet.broadcasts[0].msg, 'hello memo') assertStoreEmpty: (store) => assert.equal(store.posts.length, 0)
// Navigated to the recent feed after posting.
assert.deepEqual(navigations, ['/posts/recent'])
// The feed reflects the new post from this address.
assert.equal(store.posts.length, 1)
assert.equal(store.posts[0].text, 'hello memo')
})
test('posting an empty memo 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, 'memo_validation')
assert.equal(page.submitError, 'memo_validation')
assert.equal(page.posting, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(store.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, store, 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(store.posts.length, 0)
assert.deepEqual(navigations, [])
}) })
test('the new post page starts idle (not posting)', () => { test('the new post page starts idle (not posting)', () => {
+4 -2
View File
@@ -11,14 +11,16 @@ const { fakeWallet } = require('../helpers/fake-wallet')
// actionKey - the page's action dependency key ('memoPost' or 'memoSetName') // actionKey - the page's action dependency key ('memoPost' or 'memoSetName')
// storeKey - the action's store dependency key ('feed' or 'profiles') // storeKey - the action's store dependency key ('feed' or 'profiles')
// storeFactory - () => a fresh store // storeFactory - () => a fresh store
function buildPage ({ Page, Action, actionKey, storeKey, storeFactory }) { // pageDeps - extra dependencies passed to the page constructor
function buildPage ({ Page, Action, actionKey, storeKey, storeFactory, pageDeps = {} }) {
const wallet = fakeWallet() const wallet = fakeWallet()
const store = storeFactory() const store = storeFactory()
const action = new Action({ wallet, [storeKey]: store }) const action = new Action({ wallet, [storeKey]: store })
const navigations = [] const navigations = []
const page = new Page({ const page = new Page({
[actionKey]: action, [actionKey]: action,
navigate: (path) => navigations.push(path) navigate: (path) => navigations.push(path),
...pageDeps
}) })
return { wallet, store, action, page, navigations } return { wallet, store, action, page, navigations }
} }
+66 -1
View File
@@ -63,4 +63,69 @@ function registerPageControllerTests (cfg) {
}) })
} }
module.exports = { registerPageControllerTests } // 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 }
+113
View File
@@ -0,0 +1,113 @@
/*
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 { 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')
})
+38
View File
@@ -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'
})