Refactor set-bio to share profile-text action and page bases

Deduplicate the set-bio and set-name action and page-controller layers:
move byte-limit and profile-store reflection into MemoAction via config,
and share a ProfileTextPage base for both page controllers. Add property
tests for byte counting and the bio byte-limit boundary.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-27 08:00:58 -07:00
parent 01a16769bd
commit 9c5767d462
8 changed files with 301 additions and 120 deletions
+12 -12
View File
@@ -27,24 +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 bio for the authenticated address. Falls back to null when no
// wallet, profile store, or stored bio exists.
// The current display name for the authenticated address.
getName () {
return this._getProfileField('getName')
}
// The current bio for the authenticated address.
getBio () {
const address = this.getAddress()
if (!address || !this.profiles || typeof this.profiles.getBio !== 'function') {
return null
}
return this.profiles.getBio(address)
return this._getProfileField('getBio')
}
// Whether the account page exposes a Set Name button.
@@ -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.
+3 -19
View File
@@ -17,7 +17,6 @@
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_SET_BIO_PREFIX = '6d05'
const MAX_BIO_BYTES = 217
@@ -29,17 +28,9 @@ class MemoSetBio extends MemoAction {
lengthMessage: `Bio is too long. Maximum is ${MAX_BIO_BYTES} bytes.`,
emptyMessage: 'Bio must not be empty.',
lengthCode: 'bio_length',
validationCode: 'bio_validation'
}
constructor (deps = {}) {
super(deps)
this.profiles = deps.profiles
}
// A bio is over-length when it exceeds the byte limit.
isTooLong (bio) {
return byteLength(bio) > MAX_BIO_BYTES
validationCode: 'bio_validation',
maxBytes: MAX_BIO_BYTES,
profileMethod: 'setBio'
}
// Compose and broadcast a Memo set-bio transaction for the given bio.
@@ -47,13 +38,6 @@ class MemoSetBio extends MemoAction {
async setBio (bio) {
return this.broadcast(bio)
}
// Record the new bio on the injected profile store when one is present.
reflect (txid, bio) {
if (this.profiles && typeof this.profiles.setBio === 'function') {
this.profiles.setBio(this.wallet.walletInfo.cashAddress, bio)
}
}
}
MemoSetBio.MEMO_SET_BIO_PREFIX = MEMO_SET_BIO_PREFIX
+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,59 @@
/*
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
+15 -35
View File
@@ -3,52 +3,32 @@
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) 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-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.
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 MemoSetBio = require('./memo-set-bio')
const { byteLength } = require('./utf8')
const SET_BIO_PATH = '/memo/set-bio'
const ACCOUNT_PATH = '/account'
class SetBioPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoSetBio = deps.memoSetBio || null
this.settingBio = false
this.successPath = ACCOUNT_PATH
this.validationCodes = ['bio_validation', 'bio_length']
}
// Bytes remaining before the bio limit is reached.
remainingCount () {
return MemoSetBio.MAX_BIO_BYTES - byteLength(this.input)
}
// Set the in-flight setting-bio flag.
_setBusy (value) {
this.settingBio = value
}
// Run the memo set-bio action for the current input.
async _perform (input) {
if (!this.memoSetBio) {
throw new Error('Set bio requires a memo set-bio handler.')
}
return this.memoSetBio.setBio(input)
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 = ACCOUNT_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' }
)
})