diff --git a/psf-memo-client/src/components/app-body/profile/index.js b/psf-memo-client/src/components/app-body/profile/index.js index 1899759..cab899d 100644 --- a/psf-memo-client/src/components/app-body/profile/index.js +++ b/psf-memo-client/src/components/app-body/profile/index.js @@ -11,6 +11,7 @@ import MemoDb from '../../../services/memo-db' import MemoFollow from '../../../services/memo-follow' import MemoMute from '../../../services/memo-mute' import ProfilePage from '../../../services/profile-page' +import { getViewerAddress } from '../../../services/profile-wallet' import PostReplyCount from '../../post-reply-count' import LikeButton from '../../post-feed/like-button' import PostThreadModal from '../../post-thread-modal' @@ -75,6 +76,10 @@ function Profile (props) { setThreadTxid(null) } + const wallet = appData?.wallet || null + const appProfiles = appData?.profiles || null + const myAddr = getViewerAddress(appData) + const runAction = async (method, failMsg) => { if (!profilePage || busy) return setBusy(true) @@ -98,12 +103,11 @@ function Profile (props) { try { const memoDb = new MemoDb() - const myAddr = appData?.wallet?.walletInfo?.cashAddress || null - const memoFollow = myAddr - ? new MemoFollow({ wallet: appData.wallet, profiles: appData.profiles }) + const memoFollow = myAddr && wallet + ? new MemoFollow({ wallet, profiles: appProfiles }) : null - const memoMute = myAddr - ? new MemoMute({ wallet: appData.wallet, profiles: appData.profiles }) + const memoMute = myAddr && wallet + ? new MemoMute({ wallet, profiles: appProfiles }) : null const page = new ProfilePage({ memoDb, addr, myAddr, memoFollow, memoMute }) @@ -132,7 +136,7 @@ function Profile (props) { setError('Missing profile address') setLoading(false) } - }, [addr, appData?.wallet, appData?.profiles]) + }, [addr, myAddr, wallet, appProfiles]) const showFollowButton = profilePage && profilePage.canFollow() && !profilePage.isFollowing() const showUnfollowButton = profilePage && profilePage.canFollow() && profilePage.isFollowing() diff --git a/psf-memo-client/src/services/profile-wallet.js b/psf-memo-client/src/services/profile-wallet.js new file mode 100644 index 0000000..a54df16 --- /dev/null +++ b/psf-memo-client/src/services/profile-wallet.js @@ -0,0 +1,19 @@ +/* + Wallet address selection for the profile page. + + The React app stores the authenticated wallet address in two places: + `appData.bchWalletState.cashAddress` (reactive state updated as the wallet + initializes) and `appData.wallet.walletInfo.cashAddress` (the wallet object + itself). Prefer the reactive state so the profile page re-loads when the + address becomes available. +*/ + +'use strict' + +function getViewerAddress (appData) { + return appData?.bchWalletState?.cashAddress || + appData?.wallet?.walletInfo?.cashAddress || + null +} + +module.exports = { getViewerAddress } diff --git a/psf-memo-client/test/property/profile-wallet.property.test.js b/psf-memo-client/test/property/profile-wallet.property.test.js new file mode 100644 index 0000000..179fd4b --- /dev/null +++ b/psf-memo-client/test/property/profile-wallet.property.test.js @@ -0,0 +1,111 @@ +/* + Property tests for the profile page wallet-address selector. + + The unit tests check getViewerAddress at a few fixed shapes. These + properties pin the precedence invariant over a broad input space: + + - precedence: whenever the reactive bchWalletState.cashAddress is a truthy + value it is returned, regardless of the wallet-field address. + - fallback: when the reactive state carries no truthy address, the wallet + object's walletInfo.cashAddress is used when present. + - absence: when neither source has an address, the result is null, never a + partial/empty object. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll, intGen } = require('./harness') +const { getViewerAddress } = require('../../src/services/profile-wallet') + +const rng = seededRandom(20260903) +const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' + +// Random cash-address-shaped string (never empty so it passes as truthy). +function addressGen () { + const len = intGen(rng, 40, 60)() + let out = 'bitcoincash:q' + for (let i = 0; i < len; i++) { + out += CHARS[Math.floor(rng() * CHARS.length)] + } + return out +} + +// Pick a "reactive state address" which may be falsy (undefined/null/'') or a +// real address string. +function stateAddrGen () { + const roll = rng() + if (roll < 0.2) return undefined + if (roll < 0.4) return null + if (roll < 0.55) return '' + return addressGen() +} + +// Build a wallet field: absent, present without an address, or present with a +// randomly chosen address. +function walletGen () { + const roll = rng() + if (roll < 0.25) return undefined + if (roll < 0.4) return { walletInfo: {} } + return { walletInfo: { cashAddress: addressGen() } } +} + +function buildAppData (stateAddr, wallet) { + return { + bchWalletState: { cashAddress: stateAddr }, + wallet, + profiles: {} + } +} + +function expectedGet (stateAddr, wallet) { + if (stateAddr) return stateAddr + if (wallet && wallet.walletInfo && wallet.walletInfo.cashAddress) { + return wallet.walletInfo.cashAddress + } + return null +} + +function fixtureGen () { + return () => ({ + stateAddr: stateAddrGen(), + wallet: walletGen() + }) +} + +const gen = fixtureGen() + +test('getViewerAddress matches the reactive-then-wallet precedence rule', async () => { + await forAll( + gen, + ({ stateAddr, wallet }) => { + const appData = buildAppData(stateAddr, wallet) + return getViewerAddress(appData) === expectedGet(stateAddr, wallet) + }, + { label: 'getViewerAddress precedence' } + ) +}) + +test('getViewerAddress prefers the reactive state whenever it is truthy', async () => { + await forAll( + gen, + ({ stateAddr, wallet }) => { + if (!stateAddr) return true + const appData = buildAppData(stateAddr, wallet) + return getViewerAddress(appData) === stateAddr + }, + { label: 'getViewerAddress reactive preference invariant' } + ) +}) + +test('getViewerAddress returns null when no wallet address is available', async () => { + await forAll( + gen, + ({ stateAddr, wallet }) => { + if (expectedGet(stateAddr, wallet) !== null) return true + const appData = buildAppData(stateAddr, wallet) + return getViewerAddress(appData) === null + }, + { label: 'getViewerAddress null-on-absent invariant' } + ) +}) diff --git a/psf-memo-client/test/unit/profile-wallet.test.js b/psf-memo-client/test/unit/profile-wallet.test.js new file mode 100644 index 0000000..2a5b793 --- /dev/null +++ b/psf-memo-client/test/unit/profile-wallet.test.js @@ -0,0 +1,60 @@ +/* + Unit tests for the profile wallet address selector. + + The profile page must show follow/mute controls only when the viewer's + wallet address is known. The React app tracks the loaded address both in the + wallet object and in `bchWalletState`; the selector prefers the reactive + state so the profile reloads when the address becomes available. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { getViewerAddress } = require('../../src/services/profile-wallet') + +function makeAppData ({ bchCashAddress, walletCashAddress } = {}) { + return { + bchWalletState: { + cashAddress: bchCashAddress + }, + wallet: walletCashAddress + ? { walletInfo: { cashAddress: walletCashAddress } } + : null, + profiles: {} + } +} + +test('getViewerAddress prefers bchWalletState.cashAddress', () => { + const appData = makeAppData({ + bchCashAddress: 'bitcoincash:state-address', + walletCashAddress: 'bitcoincash:wallet-address' + }) + + assert.equal(getViewerAddress(appData), 'bitcoincash:state-address') +}) + +test('getViewerAddress falls back to wallet.walletInfo.cashAddress', () => { + const appData = makeAppData({ + bchCashAddress: undefined, + walletCashAddress: 'bitcoincash:wallet-address' + }) + + assert.equal(getViewerAddress(appData), 'bitcoincash:wallet-address') +}) + +test('getViewerAddress returns null when no wallet is loaded', () => { + const appData = makeAppData({}) + + assert.equal(getViewerAddress(appData), null) +}) + +test('getViewerAddress returns null when wallet lacks an address', () => { + const appData = { + bchWalletState: {}, + wallet: { walletInfo: {} }, + profiles: {} + } + + assert.equal(getViewerAddress(appData), null) +}) diff --git a/specifier-prompt.md b/specifier-prompt.md index cb45f27..f07282c 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -359,6 +359,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: `d3903fa` (backlog cleared of all previously listed -features; direction set to front-end improvements to `psf-memo-client`). +Current `master` HEAD: `d99eda3` (merged architect's notifications refactor + +startup bloat check; verified db 315 passing + lint clean, client 239 passing + +lint clean + build succeeds). Next action: **ask the user for the next front-end improvement to spec**. diff --git a/swarmforge/scripts/gherkin-parser b/swarmforge/scripts/gherkin-parser new file mode 100755 index 0000000..9e053cd --- /dev/null +++ b/swarmforge/scripts/gherkin-parser @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Wrapper for the Babashka APS gherkin-parser task. +# Delegates to the project-local APS checkout under .swarmforge/tools/aps. +set -euo pipefail +if [ "$#" -ne 2 ]; then + echo "usage: gherkin-parser " >&2 + exit 2 +fi +FEATURE_FILE="$(realpath -m "$1")" +JSON_OUTPUT="$(realpath -m "$2")" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APS_DIR="$(cd "$SCRIPT_DIR/../../.swarmforge/tools/aps" && pwd)" +cd "$APS_DIR" +exec bb gherkin-parser "$FEATURE_FILE" "$JSON_OUTPUT"