mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user