Review and harden set-bio profile-text refactor

Merge refactorer set-bio work: MemoAction config-driven byte-limit and
profile-store reflection, shared ProfileTextPage base for SetBioPage and
SetNamePage, and byte-counting property tests. Harden the shared base with
a test asserting the in-flight flag initializes false.

By architect.
This commit is contained in:
Chris Troutner
2026-08-27 08:09:02 -07:00
21 changed files with 1217 additions and 91 deletions
+83
View File
@@ -0,0 +1,83 @@
# Review summary: set-bio
**Architect review of the refactorer handoff for task `set-bio`.**
## Commits reviewed
- `fcab46d` (specifier): Add Gherkin spec for Set Bio (`0x6d05`) client write path.
- `01a1676` (coder): Implement Set Bio (`0x6d05`) client write path — `memo-set-bio.js`,
`set-bio-page.js`, account/set-bio React components, acceptance handlers, and unit tests.
- `9c5767d462` (refactorer): Refactor set-bio to share profile-text action and page bases —
moved byte-limit and profile-store reflection into `MemoAction` via config (`maxBytes`,
`profileMethod`), extracted a shared `ProfileTextPage` base for `SetBioPage`/`SetNamePage`,
generalized `AccountPage._getProfileField`, and added byte-counting property tests.
Merged with the prior chain commits (`5926db1` specifier backlog update). The unrelated
`swarmforge.conf` specifier-model change carried on the refactorer branch was left out of
this review merge.
## Architectural findings and fixes applied
The refactorer's structure is sound. `MemoSetBio`/`MemoSetName` are now thin config-driven
subclasses of `MemoAction`; `SetBioPage`/`SetNamePage` share the `ProfileTextPage` base,
which stays free of UI/network concerns behind injected handler/navigate adapters.
UI/Core separation, dependency direction, and information hiding all hold. The client
remains the only component touched; no `psf-memo-db`/`psf-memo-indexer` changes.
I applied one hardening fix (test addition only; no production code changed):
- **`psf-memo-client/test/unit/set-bio-page.test.js`**: added a test asserting the page's
in-flight flag (`settingBio`) initializes to `false`, killing the sole survivor in the
shared `ProfileTextPage` base constructor.
## Verification results
### Language mutation (`mutate4javascript`, differential vs manifest, `--max-workers 8`)
Every changed testable source file is fully killed (0 survivors, 0 uncovered):
- `psf-memo-client/src/services/profile-text-page.js` — 3/3 killed (new base)
- `psf-memo-client/src/services/account-page.js` — 3/3 killed
- `psf-memo-client/src/services/memo-action.js` — 3/3 killed
- `psf-memo-client/src/services/profiles.js` — 1/1 killed
- `memo-set-bio.js`, `memo-set-name.js`, `set-bio-page.js`, `set-name-page.js` — 0 sites
(thin config wrappers; their logic lives in the fully-killed `MemoAction`/`ProfileTextPage`)
The React components (`app-body/account`, `app-body/set-bio`, `app-body`) are JSX UI
modules the mutation tool cannot parse; per the constitution these environmentally
unsuitable UI modules are excluded from mutation testing, and their testable logic lives
in the services above.
### DRY (`dry4javascript`)
One structural match reported: `MemoSetBio` and `MemoSetName` class shells (score 1.00).
This is the expected residual pattern of two config-only sibling subclasses of the same
`MemoAction` base; the structural fingerprint normalizes away the distinguishing prefix,
messages, byte limits, and method names. Merging them into a single class would forfeit
the distinct protocol constants (`0x6d01` vs `0x6d05`, `MAX_NAME_BYTES` vs `MAX_BIO_BYTES`)
the pages rely on, with no runtime duplication to remove. Retained as intentional siblings.
### CRAP / cyclomatic complexity (`crap4javascript`)
All changed functions within threshold (max CC 4, CRAP 4.0, 100% branch coverage on all
except the defensive `MemoAction.isTooLong` guard at 66.7% / CRAP 2.1).
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
**psf-memo-client** `set-bio.feature`: 13 discovered, 12 executed, **6 killed, 6 survived**.
- Scenario 1 (valid bio broadcast) `m1`,`m2`: mutated capitalization of the bio example
survives because the assertion echoes the broadcast value from the same example
(weak/tautological example-to-assertion connection). Specifier-side feature-quality item.
- Scenario 3 (over-long bio rejected) `m4`,`m5`: mutated chars still leave the bio over the
217-byte limit, so rejection is unchanged — genuine equivalents.
- Scenario 4 (byte counter) `m9`,`m13`: mutated chars leave the byte length unchanged, so
the remaining count is identical — genuine equivalents.
No implementation changes are warranted; the equivalents are intrinsic and the
tautological-assertion cases are specifier feature-quality improvements.
## Suite status
- `psf-memo-client`: unit **48 passing**, property **6 passing**, acceptance **pass**
(7 feature files), lint **pass**, build **success**.
## Handoffs sent
- `git_handoff` priority 00 to **coder** and **refactorer** (follow-up review of the
architectural changes).
- No specifier handoff: no specification changes in this commit (the set-bio feature-file
mutation manifest is tool-generated metadata; the weak-scenario findings are recorded
here for the specifier in the durable report).
By architect.
+106 -2
View File
@@ -25,6 +25,8 @@ 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 MemoSetBio = require('../../src/services/memo-set-bio')
const SetBioPage = require('../../src/services/set-bio-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
@@ -35,6 +37,7 @@ const ThreadPage = require('../../src/services/thread-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
const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
@@ -73,13 +76,17 @@ function makeFeed () {
}
}
// A fake profile store recording display names set for addresses.
// A fake profile store recording display names and bios set for addresses.
function makeProfiles () {
const names = {}
const bios = {}
return {
names,
bios,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null
getName: (addr) => names[addr] || null,
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null
}
}
@@ -177,6 +184,14 @@ function createWorld () {
memoSetName,
navigate: (path) => { world.currentPath = path }
})
// The Set Bio Page controller shares the same profile store.
const memoSetBio = new MemoSetBio({ wallet, profiles })
world.setBioPage = new SetBioPage({
memoSetBio,
navigate: (path) => { world.currentPath = path }
})
world.accountPage = new AccountPage({
wallet,
profiles,
@@ -313,6 +328,17 @@ const handlers = [
world.newPage.setInput(example[param])
}
},
{
name: 'type bio text',
pattern: /^I type a bio 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.setBioPage.setInput(example[param])
}
},
{
name: 'type name text',
pattern: /^I type a name with the text "<([A-Za-z0-9_]+)>"$/,
@@ -331,6 +357,13 @@ const handlers = [
await world.newPage.submit()
}
},
{
name: 'submit bio',
pattern: /^I submit the bio$/,
async run (m, example, world) {
await world.setBioPage.submit()
}
},
{
name: 'submit name',
pattern: /^I submit the name$/,
@@ -442,6 +475,13 @@ const handlers = [
})
}
},
{
name: 'click Set Bio button',
pattern: /^I click the Set Bio button$/,
run (m, example, world) {
world.accountPage.clickSetBio()
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
@@ -483,6 +523,23 @@ const handlers = [
}
}
},
{
name: 'broadcasts OP_RETURN with Memo set-profile prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-profile 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_BIO_PREFIX) {
throw new Error(`Expected Memo set-profile prefix ${MEMO_SET_BIO_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== world.setBioPage.input) {
throw new Error('Broadcast bio text did not match the typed bio.')
}
}
},
{
name: 'broadcasts OP_RETURN with Memo reply prefix',
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/,
@@ -594,6 +651,17 @@ const handlers = [
}
}
},
{
name: 'set bio page shows validation/length error',
pattern: /^the set bio page shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'bio_validation' : 'bio_length'
if (world.setBioPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.setBioPage.submitError}.`)
}
}
},
{
name: 'remaining character count',
pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/,
@@ -624,6 +692,21 @@ const handlers = [
}
}
},
{
name: 'set bio remaining byte count',
pattern: /^the set bio 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.setBioPage.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$/,
@@ -645,6 +728,18 @@ const handlers = [
}
}
},
{
name: 'account page shows bio',
pattern: /^the account page shows my bio as "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
const actual = world.accountPage.getBio()
if (actual !== expected) {
throw new Error(`Expected account bio "${expected}", got "${actual}".`)
}
}
},
{
name: 'account page shows Set Name button',
pattern: /^the account page shows a Set Name button$/,
@@ -654,6 +749,15 @@ const handlers = [
}
}
},
{
name: 'account page shows Set Bio button',
pattern: /^the account page shows a Set Bio button$/,
run (m, example, world) {
if (!world.accountPage.hasSetBioButton()) {
throw new Error('Account page does not show a Set Bio button.')
}
}
},
{
name: 'wallet has spendable balance',
pattern: /^the wallet has a spendable balance of (.+) sats$/,
+64
View File
@@ -0,0 +1,64 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-27T15:07:16.579432218Z","feature_name":"Set Bio","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/set-bio.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Set Bio - 2 an empty bio is rejected on the set bio page","scenario_hash":"13afeec034e9b128e3e2c2c82f392648a11407b917c33c53d13185cc9d8bf2b7","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-27T15:06:58.731115193Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Set Bio - 1, Set Bio - 2, Set Bio - 3, Set Bio - 4, Set Bio - 5
Feature: Set Bio
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Set Bio - 1 a valid bio is broadcast and the user lands on the account page
Given I navigate to the path /memo/set-bio
When I type a bio with the text "<text>"
When I submit the bio
Then the app broadcasts an OP_RETURN transaction with the Memo set-profile prefix
Then I navigate to the path /account
Then the account page shows my bio as "<text>"
Examples:
| text |
| Building the future on Bitcoin Cash |
| a longer bio with spaces and punctuation |
Scenario Outline: Set Bio - 2 an empty bio is rejected on the set bio page
Given I navigate to the path /memo/set-bio
When I type a bio with the text "<text>"
When I submit the bio
Then the set bio page shows a validation error
Then the app does not broadcast any transaction
Examples:
| text |
| |
Scenario Outline: Set Bio - 3 an over-long bio is rejected on the set bio page
Given I navigate to the path /memo/set-bio
When I type a bio with the text "<text>"
When I submit the bio
Then the set bio page shows a length error
Then the app does not broadcast any transaction
Examples:
| text |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀 |
Scenario Outline: Set Bio - 4 the byte counter counts down from the bio limit
Given I navigate to the path /memo/set-bio
When I type a bio with the text "<text>"
Then the set bio page shows a remaining byte count of <count>
Examples:
| text | count |
| | 217 |
| hello | 212 |
| é | 215 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
Scenario: Set Bio - 5 the account page links to the set bio page
Given I navigate to the path /account
Then the account page shows a Set Bio button
When I click the Set Bio button
Then I navigate to the path /memo/set-bio
@@ -20,28 +20,33 @@ function Account (props) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [name, setName] = useState(null)
const [bio, setBio] = useState(null)
const wallet = appData?.wallet
const address = wallet?.walletInfo?.cashAddress || ''
useEffect(() => {
const loadName = async () => {
const loadAccount = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const profile = await memoDb.getName(address)
setName(profile?.name || null)
const [profile, nameDoc] = await Promise.all([
memoDb.getProfile(address),
memoDb.getName(address)
])
setBio(profile?.text || null)
setName(nameDoc?.name || null)
} catch (err) {
setError(err.message || 'Failed to load name')
setError(err.message || 'Failed to load account')
}
setLoading(false)
}
if (address) {
loadName()
loadAccount()
} else {
setLoading(false)
}
@@ -53,7 +58,8 @@ function Account (props) {
navigate
})
const displayName = name || accountPage.getName() || truncateAddr(address, 24)
const displayName = accountPage.getName() || name || truncateAddr(address, 24)
const displayBio = accountPage.getBio() || bio || ''
return (
<Container className='account-page mt-4'>
@@ -77,6 +83,10 @@ function Account (props) {
<strong>Name: </strong>
{displayName}
</p>
<p className='account-bio'>
<strong>Bio: </strong>
{displayBio || <span className='text-muted'>No bio set</span>}
</p>
<p className='account-address'>
<strong>Address: </strong>
{address}
@@ -85,11 +95,21 @@ function Account (props) {
{accountPage.hasSetNameButton() && (
<Button
variant='primary'
className='me-2'
onClick={() => accountPage.clickSetName()}
>
Set Name
</Button>
)}
{accountPage.hasSetBioButton() && (
<Button
variant='primary'
onClick={() => accountPage.clickSetBio()}
>
Set Bio
</Button>
)}
</div>
)}
</Col>
@@ -28,6 +28,7 @@ import RecentPosts from './posts'
import NewPost from './new-post'
import Profile from './profile'
import SetName from './set-name'
import SetBio from './set-bio'
import Account from './account'
function AppBody (props) {
@@ -47,6 +48,7 @@ function AppBody (props) {
<Route path='/posts/recent' element={<RecentPosts appData={appData} />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/account' element={<Account appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
@@ -0,0 +1,95 @@
/*
Set Bio view: compose and broadcast a Memo profile text, with a byte counter
that counts down from the bio 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 MemoSetBio from '../../../services/memo-set-bio'
import SetBioPage from '../../../services/set-bio-page'
import { byteLength } from '../../../services/utf8'
function SetBio (props) {
const { appData } = props
const navigate = useNavigate()
const maxBytes = MemoSetBio.MAX_BIO_BYTES
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [settingBio, setSettingBio] = useState(false)
const remaining = maxBytes - byteLength(input)
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setSettingBio(true)
try {
const memoSetBio = new MemoSetBio({ wallet: appData?.wallet, profiles: appData?.profiles })
const page = new SetBioPage({ memoSetBio, navigate })
page.setInput(input)
const result = await page.submit()
if (!result.ok) {
if (result.error === 'bio_length') {
setErr(`Bio is too long. Maximum is ${maxBytes} bytes.`)
} else if (result.error === 'bio_validation') {
setErr('Bio must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to set bio.')
}
}
// On success page.submit() navigated to the account page.
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setSettingBio(false)
}
}
return (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='set-bio-heading'>
<h1>Set Bio</h1>
<p>Set your profile bio and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='set-bio-input' className='mb-3'>
<Form.Label><b>Bio</b></Form.Label>
<Form.Control
as='textarea'
rows={4}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write a short bio...'
/>
</Form.Group>
<p className='set-bio-counter'>
{remaining} bytes remaining
</p>
{err && <p className='set-bio-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={settingBio}>
{settingBio ? 'Setting Bio...' : 'Set Bio'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default SetBio
+28 -6
View File
@@ -12,6 +12,7 @@
*/
const SET_NAME_PATH = '/memo/set-name'
const SET_BIO_PATH = '/memo/set-bio'
const ACCOUNT_PATH = '/account'
class AccountPage {
@@ -26,14 +27,24 @@ class AccountPage {
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 () {
// Read a profile field for the authenticated address. Falls back to null
// when no wallet, profile store, or stored field exists.
_getProfileField (method) {
const address = this.getAddress()
if (!address || !this.profiles || typeof this.profiles.getName !== 'function') {
if (!address || !this.profiles || typeof this.profiles[method] !== 'function') {
return null
}
return this.profiles.getName(address)
return this.profiles[method](address)
}
// The current display name for the authenticated address.
getName () {
return this._getProfileField('getName')
}
// The current bio for the authenticated address.
getBio () {
return this._getProfileField('getBio')
}
// Whether the account page exposes a Set Name button.
@@ -41,17 +52,28 @@ class AccountPage {
return true
}
// Whether the account page exposes a Set Bio button.
hasSetBioButton () {
return true
}
// Click the Set Name button: navigate to the set-name page.
clickSetName () {
this.navigate(SET_NAME_PATH)
}
// Click the Set Bio button: navigate to the set-bio page.
clickSetBio () {
this.navigate(SET_BIO_PATH)
}
}
AccountPage.SET_NAME_PATH = SET_NAME_PATH
AccountPage.SET_BIO_PATH = SET_BIO_PATH
AccountPage.ACCOUNT_PATH = ACCOUNT_PATH
module.exports = AccountPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T12:18:58.560Z","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"}]}
// {"version":1,"tested_at":"2026-08-27T15:05:46.167Z","module_hash":"5368f9a79434a29fa2879eb387a41426fbb869348e9bcb620878622f4fd3178f","functions":[{"id":"func/AccountPage.constructor","name":"AccountPage.constructor","line":19,"end_line":23,"hash":"89f261283d2ceab1023088c80e89e48e21d1b62dbc2241a6e9fb00b5653d0607"},{"id":"func/AccountPage.getAddress","name":"AccountPage.getAddress","line":26,"end_line":28,"hash":"dd06e8414856559223a8fd5bd68193d8e04ea6264e3ac7c08e80c8dea69e2a36"},{"id":"func/AccountPage._getProfileField","name":"AccountPage._getProfileField","line":32,"end_line":38,"hash":"f9cfeda61b974259daa7204c803aa186efa79f97cb56f5af4d2b502567da7674"},{"id":"func/AccountPage.getName","name":"AccountPage.getName","line":41,"end_line":43,"hash":"fd06a52ab7c03ab8e78702d3b05851294957ae30842f5649d3dbe7014d1d423f"},{"id":"func/AccountPage.getBio","name":"AccountPage.getBio","line":46,"end_line":48,"hash":"0e52e9086ca7c1a78dfb1025977356c453bfb4ad036a418afd92ccf700b6c394"},{"id":"func/AccountPage.hasSetNameButton","name":"AccountPage.hasSetNameButton","line":51,"end_line":53,"hash":"49dc20060d4c55606057a926132f0cc5c8154548a445b299927ef68b9da86ca3"},{"id":"func/AccountPage.hasSetBioButton","name":"AccountPage.hasSetBioButton","line":56,"end_line":58,"hash":"9bfca400cd4dc62fb73911c270e5628746eaf1efbb50aa51e3bc98df4a1b05ec"},{"id":"func/AccountPage.clickSetName","name":"AccountPage.clickSetName","line":61,"end_line":63,"hash":"82ff3b1da4068cbb8b78d55a9dfbd366c78927b67c7d4aa96c4cda12e3144f38"},{"id":"func/AccountPage.clickSetBio","name":"AccountPage.clickSetBio","line":66,"end_line":68,"hash":"97464fb4db204c66fd95beacc8d1e892ae92e30ae0da1dea9dd0f0efc85077d9"}]}
// mutate4javascript-manifest-end
+37 -1
View File
@@ -7,8 +7,15 @@
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
Optional config keys enable shared defaults for a profile text action:
maxBytes - byte limit used by the default isTooLong(value)
profileMethod - injected profile store method used by the default
reflect(txid, value)
*/
const { byteLength } = require('./utf8')
class MemoAction {
constructor (deps = {}) {
this.wallet = deps.wallet
@@ -19,6 +26,35 @@ class MemoAction {
this.emptyMessage = cfg.emptyMessage
this.lengthCode = cfg.lengthCode
this.validationCode = cfg.validationCode
this.maxBytes = cfg.maxBytes ?? null
this.profileMethod = cfg.profileMethod ?? null
// Profile text actions (config.profileMethod set) receive the injected
// profile store here so subclasses do not each re-wire it.
if (this.profileMethod) {
this.profiles = deps.profiles
}
}
// Default over-length check driven by the config maxBytes. Subclasses that
// measure limits differently override this method.
isTooLong (value) {
if (this.maxBytes === null) {
throw new Error('isTooLong must be provided by the subclass.')
}
return byteLength(value) > this.maxBytes
}
// Default reflect that records the value on the injected profile store
// method named by the config profileMethod. Subclasses with other stores
// override this method.
reflect (txid, value) {
if (
this.profileMethod &&
this.profiles &&
typeof this.profiles[this.profileMethod] === 'function'
) {
this.profiles[this.profileMethod](this.wallet.walletInfo.cashAddress, value)
}
}
// Validate a candidate value.
@@ -73,5 +109,5 @@ class MemoAction {
module.exports = MemoAction
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T12:18:08.027Z","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"}]}
// {"version":1,"tested_at":"2026-08-27T15:06:04.806Z","module_hash":"c13b38d46acf1640411785ec6f78e03770c1608259e30dbafff054d3775b8700","functions":[{"id":"func/MemoAction.constructor","name":"MemoAction.constructor","line":20,"end_line":36,"hash":"c0dd70bac41e1f9b511c90fc12e5b2f7ea482fa8647d9a26cdfc8ba756de9690"},{"id":"func/MemoAction.isTooLong","name":"MemoAction.isTooLong","line":40,"end_line":45,"hash":"ee208b9282d0a225ca8a8acca665d288cdb66f351dceb15fadd67df1de493343"},{"id":"func/MemoAction.reflect","name":"MemoAction.reflect","line":50,"end_line":58,"hash":"1f94e4db07b0c26cefbbdab9c2641790f772c7c245d78b8864302c242aee930f"},{"id":"func/MemoAction.validate","name":"MemoAction.validate","line":62,"end_line":72,"hash":"b8598a392b3a65b5f1fe329048a041a087ef0735806fd03f42fe0cf7e19ef7fc"},{"id":"func/MemoAction.broadcast","name":"MemoAction.broadcast","line":76,"end_line":95,"hash":"07853c0eec474cae372e901db388b62b50020db0aff1f63bb587b9e494f4ede5"},{"id":"func/MemoAction._throwIfInvalid","name":"MemoAction._throwIfInvalid","line":98,"end_line":106,"hash":"dafb785969f30b0fa347c8e699e4bf3302ce3a9ef0d3481f1ffa5c276992c808"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,50 @@
/*
Memo set-bio behavior: compose, validate, and broadcast a Memo "set profile
text" message.
A Memo set-bio transaction is an OP_RETURN Bitcoin Cash transaction carrying
the Memo set-profile protocol prefix (0x6d05) followed by the bio 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_BIO_PREFIX : hex prefix for the Memo "set profile text" action (0x6d05)
MAX_BIO_BYTES : maximum allowed bio length (217 bytes per memo.sv)
*/
const MemoAction = require('./memo-action')
const MEMO_SET_BIO_PREFIX = '6d05'
const MAX_BIO_BYTES = 217
class MemoSetBio extends MemoAction {
static config = {
prefix: MEMO_SET_BIO_PREFIX,
walletRequiredMsg: 'Memo set bio requires a wallet.',
lengthMessage: `Bio is too long. Maximum is ${MAX_BIO_BYTES} bytes.`,
emptyMessage: 'Bio must not be empty.',
lengthCode: 'bio_length',
validationCode: 'bio_validation',
maxBytes: MAX_BIO_BYTES,
profileMethod: 'setBio'
}
// Compose and broadcast a Memo set-bio transaction for the given bio.
// Resolves with the transaction id, or rejects with a typed error.
async setBio (bio) {
return this.broadcast(bio)
}
}
MemoSetBio.MEMO_SET_BIO_PREFIX = MEMO_SET_BIO_PREFIX
MemoSetBio.MAX_BIO_BYTES = MAX_BIO_BYTES
module.exports = MemoSetBio
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T15:04:21.411Z","module_hash":"d782ad96d6e43c76eceee7cd8398f990f4fc22f8867c24afc6ceeaf263ee7cb8","functions":[{"id":"func/MemoSetBio.setBio","name":"MemoSetBio.setBio","line":38,"end_line":40,"hash":"c0109c9559cc2ec6c8deb30b5b6ed951af9138ab303c795bf55d5ab931a40d0d"}]}
// mutate4javascript-manifest-end
+3 -19
View File
@@ -17,7 +17,6 @@
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_SET_NAME_PREFIX = '6d01'
const MAX_NAME_BYTES = 77
@@ -29,17 +28,9 @@ class MemoSetName extends MemoAction {
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
validationCode: 'name_validation',
maxBytes: MAX_NAME_BYTES,
profileMethod: 'setName'
}
// Compose and broadcast a Memo set-name transaction for the given name.
@@ -47,13 +38,6 @@ class MemoSetName extends MemoAction {
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
@@ -0,0 +1,63 @@
/*
Shared base for page controllers that set a Memo profile text field (e.g. a
display name or a bio).
A profile text page holds the current input, counts down the remaining byte
budget, validates/broadcasts through an injected action handler, and
navigates to the account page on success.
Subclasses supply a static config:
handlerKey - deps key holding the action handler
busyKey - instance key for the in-flight flag
actionMethod - handler method to invoke for the current input
requiresMsg - error message when no handler is injected
maxBytes - the profile text field's byte limit
validationCodes - error codes for local validation failures
The handler 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 { byteLength } = require('./utf8')
const ACCOUNT_PATH = '/account'
class ProfileTextPage extends PageController {
constructor (deps = {}) {
super(deps)
const cfg = this.constructor.config
this[cfg.handlerKey] = deps[cfg.handlerKey] || null
this[cfg.busyKey] = false
this.successPath = ACCOUNT_PATH
this.validationCodes = cfg.validationCodes
}
// Bytes remaining before the profile text limit is reached.
remainingCount () {
return this.constructor.config.maxBytes - byteLength(this.input)
}
// Set the in-flight flag.
_setBusy (value) {
this[this.constructor.config.busyKey] = value
}
// Run the action handler for the current input.
async _perform (input) {
const cfg = this.constructor.config
if (!this[cfg.handlerKey]) {
throw new Error(cfg.requiresMsg)
}
return this[cfg.handlerKey][cfg.actionMethod](input)
}
}
ProfileTextPage.ACCOUNT_PATH = ACCOUNT_PATH
module.exports = ProfileTextPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T15:05:32.147Z","module_hash":"d06e97cbcfa2f5fc4b9bc481d8e3520536eb7e7ed8fe35daeedbda55bd243849","functions":[{"id":"func/ProfileTextPage.constructor","name":"ProfileTextPage.constructor","line":28,"end_line":35,"hash":"cc7d5bf86f1fcc5e71acb91ee96d75dd89110c4ff84e7ca3fec57be107a9cc70"},{"id":"func/ProfileTextPage.remainingCount","name":"ProfileTextPage.remainingCount","line":38,"end_line":40,"hash":"99ebb2601bf4ecfc6d9fcfe03a4ffcb3ec2dc7174a12ee668ce35e44083df96d"},{"id":"func/ProfileTextPage._setBusy","name":"ProfileTextPage._setBusy","line":43,"end_line":45,"hash":"b25ece6baf7159cdfbb0ddcd435617965f884d0541108f9e2d677a1e65cba970"},{"id":"func/ProfileTextPage._perform","name":"ProfileTextPage._perform","line":48,"end_line":54,"hash":"b16fe867f293e1aa356a38484a912d112e90157cb7d74966facf6b59861c1495"}]}
// mutate4javascript-manifest-end
+12 -1
View File
@@ -12,6 +12,7 @@
class Profiles {
constructor () {
this.names = new Map()
this.bios = new Map()
}
setName (addr, name) {
@@ -23,10 +24,20 @@ class Profiles {
if (!addr) return null
return this.names.get(addr) || null
}
setBio (addr, bio) {
if (!addr) return
this.bios.set(addr, bio)
}
getBio (addr) {
if (!addr) return null
return this.bios.get(addr) || null
}
}
module.exports = Profiles
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T12:17:49.004Z","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"}]}
// {"version":1,"tested_at":"2026-08-27T15:05:55.871Z","module_hash":"7c75650f72fefd5e9f8fea638c1d9ca9fad9eeebabe16e62fed4f5e828e39248","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":16,"hash":"8f7409de885759ce97767c69097a353ec557bd5212eef76ead0388174389ff24"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":18,"end_line":21,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":23,"end_line":26,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":28,"end_line":31,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":33,"end_line":36,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,34 @@
/*
Set Bio Page behavior: compose and broadcast a Memo profile text, with a
byte counter that counts down from the bio limit.
This is the testable controller behind the React "Set Bio" page. It wraps
the Memo set-bio behavior (src/services/memo-set-bio.js) through the shared
ProfileTextPage base and adds the page-level config: the injected handler
key, the in-flight flag, the byte limit, and the local validation codes.
The memoSetBio 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 ProfileTextPage = require('./profile-text-page')
const MemoSetBio = require('./memo-set-bio')
const SET_BIO_PATH = '/memo/set-bio'
class SetBioPage extends ProfileTextPage {
static config = {
handlerKey: 'memoSetBio',
busyKey: 'settingBio',
actionMethod: 'setBio',
requiresMsg: 'Set bio requires a memo set-bio handler.',
maxBytes: MemoSetBio.MAX_BIO_BYTES,
validationCodes: ['bio_validation', 'bio_length']
}
}
SetBioPage.SET_BIO_PATH = SET_BIO_PATH
SetBioPage.ACCOUNT_PATH = ProfileTextPage.ACCOUNT_PATH
module.exports = SetBioPage
+16 -35
View File
@@ -3,53 +3,34 @@
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 Memo set-name behavior (src/services/memo-set-name.js) through the
shared ProfileTextPage base and adds the page-level config: the injected
handler key, the in-flight flag, the byte limit, and the local validation
codes.
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.
of UI/network concerns; environmentally unsuitable I/O lives behind those
small adapter boundaries.
*/
const PageController = require('./page-controller')
const ProfileTextPage = require('./profile-text-page')
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)
class SetNamePage extends ProfileTextPage {
static config = {
handlerKey: 'memoSetName',
busyKey: 'settingName',
actionMethod: 'setName',
requiresMsg: 'Set name requires a memo set-name handler.',
maxBytes: MemoSetName.MAX_NAME_BYTES,
validationCodes: ['name_validation', 'name_length']
}
}
SetNamePage.SET_NAME_PATH = SET_NAME_PATH
SetNamePage.ACCOUNT_PATH = ACCOUNT_PATH
SetNamePage.ACCOUNT_PATH = ProfileTextPage.ACCOUNT_PATH
module.exports = SetNamePage
@@ -0,0 +1,157 @@
/*
Property tests for the Memo set-bio / profile-text behavior.
The unit tests probe byte counting and the byte limit at a few fixed inputs.
These properties cover broad input ranges so the invariants hold everywhere:
- round trip: byteLength(s) equals TextEncoder bytes, and decoding the
encoded form restores the original string.
- ordering: byteLength never reports fewer bytes than characters.
- conservation / boundary: a bio within the byte limit broadcasts and is
preserved exactly; a bio over the limit is rejected with bio_length and
never broadcast.
- byte budget: the Set Bio page's remaining count equals the byte budget
minus the input's byte length for any input.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const MemoSetBio = require('../../src/services/memo-set-bio')
const SetBioPage = require('../../src/services/set-bio-page')
const { byteLength } = require('../../src/services/utf8')
const rng = seededRandom(20260828)
// A pool of code points mixing ASCII and multi-byte UTF-8 so a string's byte
// length differs from its character count. Held as separate strings so no
// surrogate pair is ever split.
const POOL = ['a', 'b', 'Z', ' ', '9', 'é', 'ñ', '你', '😀']
// Build a random string of at most maxChars characters.
function randomString (maxChars) {
const len = intGen(rng, 0, maxChars)()
let out = ''
for (let i = 0; i < len; i++) {
out += POOL[Math.floor(rng() * POOL.length)]
}
return out
}
// Build a random string whose byte length is at or under the bio limit.
function inLimitBio () {
let s = randomString(intGen(rng, 0, 200)())
while (byteLength(s) > MemoSetBio.MAX_BIO_BYTES) {
s = randomString(intGen(rng, 0, 100)())
}
return s
}
// Build a random string guaranteed to exceed the bio byte limit.
function overLimitBio () {
let s = randomString(intGen(rng, 0, 300)())
while (byteLength(s) <= MemoSetBio.MAX_BIO_BYTES) {
s += '😀'.repeat(5)
}
return s
}
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeProfiles () {
const bios = {}
return {
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null
}
}
test('byteLength round-trips through TextEncoder and TextDecoder', async () => {
await forAll(
(i) => randomString(intGen(rng, 0, 60)()),
(s) => {
const bytes = new TextEncoder().encode(s)
return bytes.length === byteLength(s) &&
new TextDecoder().decode(bytes) === s
},
{ label: 'utf8 byte-length round trip' }
)
})
test('byteLength never reports fewer bytes than characters', async () => {
await forAll(
(i) => randomString(intGen(rng, 0, 60)()),
(s) => byteLength(s) >= s.length,
{ label: 'utf8 bytes >= chars' }
)
})
test('setBio broadcasts and preserves any bio within the byte limit', async () => {
await forAll(
(i) => inLimitBio(),
async (bio) => {
// An empty/whitespace bio is a validation rejection, not a length case,
// so it is out of scope for this broadcast property.
if (bio.trim().length === 0) return true
const wallet = makeWallet()
const profiles = makeProfiles()
const memoSetBio = new MemoSetBio({ wallet, profiles })
try {
await memoSetBio.setBio(bio)
} catch (err) {
return false
}
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].msg === bio &&
wallet.broadcasts[0].prefix === MemoSetBio.MEMO_SET_BIO_PREFIX &&
profiles.getBio(wallet.walletInfo.cashAddress) === bio
},
{ label: 'set-bio broadcasts and preserves an in-limit bio' }
)
})
test('setBio rejects any bio over the byte limit without broadcasting', async () => {
await forAll(
(i) => overLimitBio(),
async (bio) => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
try {
await memoSetBio.setBio(bio)
return false // an over-limit bio must be rejected
} catch (err) {
return err.code === 'bio_length' && wallet.broadcasts.length === 0
}
},
{ label: 'set-bio rejects an over-limit bio without broadcasting' }
)
})
test('the Set Bio page remaining count conserves the byte budget', async () => {
await forAll(
(i) => randomString(intGen(rng, 0, 200)()),
(bio) => {
const page = new SetBioPage({ navigate: () => {} })
page.setInput(bio)
return page.remainingCount() === MemoSetBio.MAX_BIO_BYTES - byteLength(bio)
},
{ label: 'set-bio remaining byte count is conserved' }
)
})
@@ -0,0 +1,105 @@
/*
Unit tests for the account page behavior.
The account page exposes the authenticated wallet's display name and bio,
along with buttons that navigate to the set-name and set-bio pages.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const AccountPage = require('../../src/services/account-page')
function makeProfiles () {
const names = {}
const bios = {}
return {
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null,
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null
}
}
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return { walletInfo: { cashAddress: address } }
}
test('getName returns the stored display name', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
profiles.setName(wallet.walletInfo.cashAddress, 'trout')
assert.equal(page.getName(), 'trout')
})
test('getName returns null without a wallet', () => {
const profiles = makeProfiles()
const page = new AccountPage({ profiles })
assert.equal(page.getName(), null)
})
test('getName returns null without a profile store', () => {
const wallet = makeWallet()
const page = new AccountPage({ wallet })
assert.equal(page.getName(), null)
})
test('getBio returns the stored bio', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
profiles.setBio(wallet.walletInfo.cashAddress, 'Building on BCH')
assert.equal(page.getBio(), 'Building on BCH')
})
test('getBio returns null without a wallet', () => {
const profiles = makeProfiles()
const page = new AccountPage({ profiles })
assert.equal(page.getBio(), null)
})
test('getBio returns null without a profile store', () => {
const wallet = makeWallet()
const page = new AccountPage({ wallet })
assert.equal(page.getBio(), null)
})
test('hasSetNameButton is true', () => {
const page = new AccountPage({})
assert.equal(page.hasSetNameButton(), true)
})
test('hasSetBioButton is true', () => {
const page = new AccountPage({})
assert.equal(page.hasSetBioButton(), true)
})
test('clickSetName navigates to the set-name page', () => {
const navigated = []
const page = new AccountPage({ navigate: (path) => navigated.push(path) })
page.clickSetName()
assert.deepEqual(navigated, [AccountPage.SET_NAME_PATH])
})
test('clickSetBio navigates to the set-bio page', () => {
const navigated = []
const page = new AccountPage({ navigate: (path) => navigated.push(path) })
page.clickSetBio()
assert.deepEqual(navigated, [AccountPage.SET_BIO_PATH])
})
@@ -0,0 +1,125 @@
/*
Unit tests for the Memo set-bio behavior.
The set-bio action validates that the bio is non-empty and within the
217-byte Memo protocol limit, then broadcasts it with the 0x6d05 prefix.
A successful broadcast is reflected on the injected profile store.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoSetBio = require('../../src/services/memo-set-bio')
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeProfiles () {
const bios = {}
return {
bios,
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null
}
}
test('setBio broadcasts with the Memo set-profile prefix', async () => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
await memoSetBio.setBio('Building on BCH')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoSetBio.MEMO_SET_BIO_PREFIX)
assert.equal(wallet.broadcasts[0].msg, 'Building on BCH')
})
test('setBio reflects the bio on the profile store', async () => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoSetBio = new MemoSetBio({ wallet, profiles })
await memoSetBio.setBio('Building on BCH')
assert.equal(profiles.getBio(wallet.walletInfo.cashAddress), 'Building on BCH')
})
test('setBio rejects an empty bio', async () => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
await assert.rejects(
() => memoSetBio.setBio(''),
{ code: 'bio_validation', message: /Bio must not be empty/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('setBio rejects a bio that exceeds the byte limit', async () => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
// 218 ASCII bytes is one byte over the 217 limit.
const tooLong = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1)
await assert.rejects(
() => memoSetBio.setBio(tooLong),
{ code: 'bio_length', message: /Bio is too long/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('setBio accepts a bio exactly at the byte limit', async () => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
const exactly = 'a'.repeat(MemoSetBio.MAX_BIO_BYTES)
await memoSetBio.setBio(exactly)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].msg, exactly)
})
test('setBio counts bytes, not characters', async () => {
const wallet = makeWallet()
const memoSetBio = new MemoSetBio({ wallet })
// A single multi-byte character should be counted by byte length.
await assert.rejects(
() => memoSetBio.setBio('😀'.repeat(Math.ceil(MemoSetBio.MAX_BIO_BYTES / 4) + 1)),
{ code: 'bio_length' }
)
})
test('setBio requires a wallet', async () => {
const memoSetBio = new MemoSetBio({})
await assert.rejects(
() => memoSetBio.setBio('Building on BCH'),
/Memo set bio requires a wallet/
)
})
test('setBio surfaces a broadcast failure', async () => {
const wallet = makeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
const memoSetBio = new MemoSetBio({ wallet })
await assert.rejects(
() => memoSetBio.setBio('Building on BCH'),
/broadcast failed/
)
})
@@ -0,0 +1,71 @@
/*
Unit tests for the in-memory profile store.
The profile store keeps display names and bios indexed by BCH cash address
so that pages stay in sync immediately after a broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const Profiles = require('../../src/services/profiles')
test('setName stores and getName retrieves a display name', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
assert.equal(profiles.getName(addr), 'trout')
})
test('getName returns null for an unknown address', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
assert.equal(profiles.getName(addr), null)
})
test('setName with no address does nothing', () => {
const profiles = new Profiles()
profiles.setName('', 'trout')
assert.equal(profiles.getName(''), null)
})
test('setBio stores and getBio retrieves a bio', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setBio(addr, 'Building on BCH')
assert.equal(profiles.getBio(addr), 'Building on BCH')
})
test('getBio returns null for an unknown address', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
assert.equal(profiles.getBio(addr), null)
})
test('setBio with no address does nothing', () => {
const profiles = new Profiles()
profiles.setBio('', 'Building on BCH')
assert.equal(profiles.getBio(''), null)
})
test('name and bio storage are independent', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
profiles.setBio(addr, 'Building on BCH')
assert.equal(profiles.getName(addr), 'trout')
assert.equal(profiles.getBio(addr), 'Building on BCH')
})
@@ -0,0 +1,116 @@
/*
Unit tests for the Set Bio page controller.
The page controller wraps the Memo set-bio behavior, exposes a remaining
byte count, and navigates to the account page on a successful broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const SetBioPage = require('../../src/services/set-bio-page')
const MemoSetBio = require('../../src/services/memo-set-bio')
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeMemoSetBio () {
const wallet = makeWallet()
return new MemoSetBio({ wallet })
}
test('the in-flight flag starts false', () => {
const page = new SetBioPage({ navigate: () => {} })
assert.equal(page.settingBio, false)
})
test('remainingCount returns full byte budget for empty input', () => {
const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} })
assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES)
})
test('remainingCount subtracts the byte length of the input', () => {
const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} })
page.setInput('hello')
assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 5)
})
test('remainingCount counts multi-byte characters correctly', () => {
const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} })
page.setInput('é')
assert.equal(page.remainingCount(), MemoSetBio.MAX_BIO_BYTES - 2)
})
test('submit navigates to the account page on success', async () => {
const navigated = []
const page = new SetBioPage({
memoSetBio: makeMemoSetBio(),
navigate: (path) => navigated.push(path)
})
page.setInput('Building on BCH')
const result = await page.submit()
assert.equal(result.ok, true)
assert.deepEqual(navigated, [SetBioPage.ACCOUNT_PATH])
})
test('submit records a validation error for empty input', async () => {
const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} })
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'bio_validation')
})
test('submit records a length error for over-long input', async () => {
const page = new SetBioPage({ memoSetBio: makeMemoSetBio(), navigate: () => {} })
page.setInput('a'.repeat(MemoSetBio.MAX_BIO_BYTES + 1))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'bio_length')
})
test('submit surfaces a broadcast failure', async () => {
const memoSetBio = makeMemoSetBio()
memoSetBio.wallet.sendOpReturn = async () => { throw new Error('network down') }
const page = new SetBioPage({ memoSetBio, navigate: () => {} })
page.setInput('Building on BCH')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'broadcast')
assert.match(result.message, /network down/)
})
test('submit records a broadcast error when no memo set-bio handler is injected', async () => {
const page = new SetBioPage({ navigate: () => {} })
page.setInput('Building on BCH')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'broadcast')
assert.match(result.message, /Set bio requires a memo set-bio handler/)
})
+9 -8
View File
@@ -167,16 +167,17 @@ Goal: reach feature parity with [memo.cash](https://memo.cash).
| Tier | Features | Status |
|------|----------|--------|
| **P0** | Post, Set name, Reply, Efficient pagination | ✅ shipped |
| **P1** | Like counts (read side) ✅; client display, Set profile text, Set profile picture, Follow/Unfollow | next |
| **P1** | Like counts (read side) ✅; client display ✅; Set profile text, Set profile picture, Follow/Unfollow | next |
| **P2** | Topics (list, feed, post, follow/unfollow) | later |
| **P3** | Polls (create, add option, vote) | later |
| **P4** | Mute / unmute user | later |
| **P5** | Send money memo action, MIP-0009 token exchange | later |
| **P6** | Repost, ranked feed, notifications, search, tags, following feed | later |
**Suggested next spec:** P1.2update the client feed/profile/thread UI to
read `likeCount` from the `psf-memo-db` API instead of defaulting to 0.
The like/tip broadcast UI is already implemented.
**Suggested next spec:** P1.3add a "Set Bio" UI on the Account page that
broadcasts the `0x6d05` Set profile text action. The indexer and DB already
read/store profile text; the missing piece is the client write path and the
Account page editor.
---
@@ -342,7 +343,7 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `4bd4b79` (likeCount read side merged to `master`;
psf-memo-db tests and lint passing).
Next action: **spec P1.2** — update the client feed/profile/thread UI to read
`likeCount` from the API.
Current `master` HEAD: `21373f4` (like-count-display merged to `master`;
client + DB tests, build, and lint passing).
Next action: **spec P1.3** — add a "Set Bio" UI on the Account page that
broadcasts the `0x6d05` Set profile text action.
+15 -13
View File
@@ -120,7 +120,7 @@ adding/editing the client UI.
| # | Feature | Memo action | Components | Status | Next work |
|---|---------|-------------|------------|--------|-----------|
| 1.1 | Like / tip a Memo — read side | `0x6d04` | D | ✅ | `likeCount` returned on `/posts/*` and `/posts/:txid/thread` |
| 1.2 | Like / tip a Memo — client display | `0x6d04` | C | 🟡 partial | Feed/Profile/Thread read `likeCount` from API instead of defaulting to 0 |
| 1.2 | Like / tip a Memo — client display | `0x6d04` | C | | Feed/Profile/Thread read `likeCount` from API instead of defaulting to 0 |
| 1.3 | Set profile text (bio) | `0x6d05` | C, I, D | 🟡 partial | C: add "Set Bio" UI on Account page; I/D already read |
| 1.4 | Set profile picture | `0x6d0a` | C, I, D | 🟡 partial | C: add "Set Avatar URL" UI; I/D already read |
| 1.5 | Follow a user | `0x6d06` | C, I, D | 🔴 missing | C: follow button on profile; D: follow state + following/followers lists |
@@ -129,8 +129,9 @@ adding/editing the client UI.
### Priority order within P1
1. **Like / tip a Memo — read side** ✅ DONE.
2. **Like / tip a Memo — client display** — the API now returns `likeCount`;
update the feed/profile/thread UI to read it instead of defaulting to 0.
2. **Like / tip a Memo — client display** ✅ DONE. The feed, profile, and
thread views now read `likeCount` from the API (profile shows a read-only
like button).
3. **Set profile text** — simple text broadcast + Account page UI.
4. **Set profile picture** — URL broadcast + Account page UI.
5. **Follow / Unfollow user** — social graph; enables the following feed later.
@@ -144,7 +145,8 @@ adding/editing the client UI.
- `psf-memo-db` aggregates `likesDb` into per-post `likeCount` in
`/posts/recent`, `/posts/by/:addr`, and `/posts/:txid/thread` responses.
- The client already has `MemoLike`, `LikeTipPage`, `LikeButton`, and
`LikeTipModal`; it only needs to read `likeCount` from the feed API.
`LikeTipModal`; the feed/profile/thread views now read `likeCount` from the
API (profile renders a read-only `LikeButton`).
---
@@ -211,16 +213,16 @@ Polls require a new data model and rendering. The indexer has no handler yet.
## Suggested first spec for the next session
**Like counts on posts** (P1.1, read side):
- `psf-memo-db`: add a `LikeQuery` adapter that builds a `likeCount` map from
`likesDb`, inject it into `ListRecentPosts`, `ListPostsByAddr`, and
`GetPostThread`, and return `likeCount` on every post object.
- `psf-memo-client`: read `likeCount` from the feed/profile API instead of
starting at 0.
- No indexer changes needed; the `like` handler already writes `likesDb`.
**Set profile text (bio)** (P1.3):
- `psf-memo-client`: add a "Set Bio" UI on the Account page that broadcasts the
`0x6d05` Set profile text action via `minimal-slp-wallet.sendOpReturn()`.
- `psf-memo-indexer` and `psf-memo-db` already read/store profile text, so this
is primarily a client broadcast + Account page UI feature.
- The profile page already renders `profileText` from the API; the missing piece
is the write path (broadcast) and the Account page editor.
This closes the loop on the already-implemented like/tip broadcast feature and
is the smallest end-to-end win toward memo.cash parity.
This is the next smallest end-to-end win toward memo.cash parity after the
like-count read/display loop closed.
---