From 9c5767d462670d9fbb897170c86348cc38ef6542 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 27 Aug 2026 08:00:58 -0700 Subject: [PATCH] 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. --- psf-memo-client/src/services/account-page.js | 24 +-- psf-memo-client/src/services/memo-action.js | 36 ++++ psf-memo-client/src/services/memo-set-bio.js | 22 +-- psf-memo-client/src/services/memo-set-name.js | 22 +-- .../src/services/profile-text-page.js | 59 +++++++ psf-memo-client/src/services/set-bio-page.js | 50 ++---- psf-memo-client/src/services/set-name-page.js | 51 ++---- .../test/property/set-bio.property.test.js | 157 ++++++++++++++++++ 8 files changed, 301 insertions(+), 120 deletions(-) create mode 100644 psf-memo-client/src/services/profile-text-page.js create mode 100644 psf-memo-client/test/property/set-bio.property.test.js diff --git a/psf-memo-client/src/services/account-page.js b/psf-memo-client/src/services/account-page.js index 172ef56..4cf79dc 100644 --- a/psf-memo-client/src/services/account-page.js +++ b/psf-memo-client/src/services/account-page.js @@ -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. diff --git a/psf-memo-client/src/services/memo-action.js b/psf-memo-client/src/services/memo-action.js index d68a6bd..c7f9f2f 100644 --- a/psf-memo-client/src/services/memo-action.js +++ b/psf-memo-client/src/services/memo-action.js @@ -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. diff --git a/psf-memo-client/src/services/memo-set-bio.js b/psf-memo-client/src/services/memo-set-bio.js index b9a8bde..198cfa1 100644 --- a/psf-memo-client/src/services/memo-set-bio.js +++ b/psf-memo-client/src/services/memo-set-bio.js @@ -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 diff --git a/psf-memo-client/src/services/memo-set-name.js b/psf-memo-client/src/services/memo-set-name.js index 637cc43..d6b4030 100644 --- a/psf-memo-client/src/services/memo-set-name.js +++ b/psf-memo-client/src/services/memo-set-name.js @@ -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 diff --git a/psf-memo-client/src/services/profile-text-page.js b/psf-memo-client/src/services/profile-text-page.js new file mode 100644 index 0000000..dee8195 --- /dev/null +++ b/psf-memo-client/src/services/profile-text-page.js @@ -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 diff --git a/psf-memo-client/src/services/set-bio-page.js b/psf-memo-client/src/services/set-bio-page.js index aa07c5c..784414a 100644 --- a/psf-memo-client/src/services/set-bio-page.js +++ b/psf-memo-client/src/services/set-bio-page.js @@ -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 diff --git a/psf-memo-client/src/services/set-name-page.js b/psf-memo-client/src/services/set-name-page.js index 874d808..ce62275 100644 --- a/psf-memo-client/src/services/set-name-page.js +++ b/psf-memo-client/src/services/set-name-page.js @@ -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 diff --git a/psf-memo-client/test/property/set-bio.property.test.js b/psf-memo-client/test/property/set-bio.property.test.js new file mode 100644 index 0000000..e4647e4 --- /dev/null +++ b/psf-memo-client/test/property/set-bio.property.test.js @@ -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' } + ) +})