From a0040f98c1c7adb22ef099d477b00f6f2b85c775 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 11:41:49 -0700 Subject: [PATCH 1/4] Implement like broadcast result modal Keep the like/tip modal open after a successful like and show a broadcast result instead of closing: the success message, the like transaction id, and a link to that transaction on bch.loping.net that opens in a new tab. The like count and filled heart update immediately, and dismissing the result closes the modal. The result state lives on the testable LikeTipPage controller; a server-renderable LikeResult component backs the modal and the acceptance adapter, with acceptance step handlers for the new wording. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 87 ++++++++++++ .../acceptance/lib/render-like-result.js | 20 +++ .../src/components/post-feed/like-result.js | 40 ++++++ .../components/post-feed/like-tip-modal.js | 101 ++++++++----- .../components/post-feed/post-feed-item.js | 3 +- .../src/components/post-feed/post-feed.css | 12 ++ psf-memo-client/src/services/like-tip-page.js | 48 +++++++ psf-memo-client/test/unit/like-result.test.js | 59 ++++++++ .../test/unit/like-tip-page.test.js | 134 ++++++++++++++++++ 9 files changed, 471 insertions(+), 33 deletions(-) create mode 100644 psf-memo-client/acceptance/lib/render-like-result.js create mode 100644 psf-memo-client/src/components/post-feed/like-result.js create mode 100644 psf-memo-client/test/unit/like-result.test.js create mode 100644 psf-memo-client/test/unit/like-tip-page.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index c128a24..3e49045 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -55,6 +55,7 @@ const PollVotePage = require('../../src/services/poll-vote-page') const { renderPostText } = require('./render-post') const { renderAccountAvatar } = require('./render-account-avatar') const { renderPostOptions } = require('./render-post-options') +const { renderLikeResult } = require('./render-like-result') const PostOptions = require('../../src/services/post-options') const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') @@ -1493,6 +1494,92 @@ const handlers = [ } } }, + { + name: 'like/tip modal remains open', + pattern: /^the like\/tip modal remains open$/, + run (m, example, world) { + if (!world.likeTipPage.modalOpen) { + throw new Error('Expected like/tip modal to remain open.') + } + } + }, + { + name: 'like/tip modal shows a broadcast success message', + pattern: /^the like\/tip modal shows a broadcast success message$/, + run (m, example, world) { + const page = world.likeTipPage + if (!page.showResultModal) { + throw new Error('Expected the like broadcast result to be shown.') + } + const message = page.getBroadcastMessage() + if (!message) { + throw new Error('Expected a like broadcast success message.') + } + const html = renderLikeResult({ + txid: page.lastResult.txid, + message, + explorerUrl: page.explorerUrl(page.lastResult.txid) + }) + if (!html.includes(message)) { + throw new Error(`The rendered like result does not show the message "${message}".`) + } + } + }, + { + name: 'like/tip modal shows the like transaction id', + pattern: /^the like\/tip modal shows the like transaction id$/, + run (m, example, world) { + const page = world.likeTipPage + if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { + throw new Error('Expected a successful like broadcast result.') + } + const txid = page.lastResult.txid + if (!txid) { + throw new Error('Expected the like result to include a transaction id.') + } + const html = renderLikeResult({ + txid, + message: page.getBroadcastMessage(), + explorerUrl: page.explorerUrl(txid) + }) + if (!html.includes(txid)) { + throw new Error(`The rendered like result does not show the transaction id ${txid}.`) + } + } + }, + { + name: 'like/tip modal shows a block explorer link', + pattern: /^the like\/tip modal shows a link to the block explorer for the like transaction$/, + run (m, example, world) { + const page = world.likeTipPage + if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { + throw new Error('Expected a successful like broadcast result.') + } + const txid = page.lastResult.txid + const url = page.explorerUrl(txid) + if (!url.startsWith('https://bch.loping.net/tx/')) { + throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`) + } + const html = renderLikeResult({ + txid, + message: page.getBroadcastMessage(), + explorerUrl: url + }) + if (!html.includes(`href="${url}"`)) { + throw new Error(`The rendered like result does not link to ${url}.`) + } + if (!html.includes('target="_blank"')) { + throw new Error('The rendered like explorer link does not open in a new tab.') + } + } + }, + { + name: 'dismiss like result', + pattern: /^I dismiss the like result$/, + run (m, example, world) { + world.likeTipPage.dismissResult() + } + }, { name: 'API serves post with explicit address and like count', pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/, diff --git a/psf-memo-client/acceptance/lib/render-like-result.js b/psf-memo-client/acceptance/lib/render-like-result.js new file mode 100644 index 0000000..7b0ef4b --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-like-result.js @@ -0,0 +1,20 @@ +/* + Acceptance rendering adapter for the like broadcast result. + + Renders the same LikeResult component the browser uses to a static HTML + string, so acceptance assertions can inspect the success message, the like + transaction id, and the block explorer link without running a browser. +*/ + +'use strict' + +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const LikeResult = require('../../src/components/post-feed/like-result') + +function renderLikeResult ({ txid = '', message = '', explorerUrl = '' } = {}) { + const element = React.createElement(LikeResult, { txid, message, explorerUrl }) + return ReactDOMServer.renderToStaticMarkup(element) +} + +module.exports = { renderLikeResult } diff --git a/psf-memo-client/src/components/post-feed/like-result.js b/psf-memo-client/src/components/post-feed/like-result.js new file mode 100644 index 0000000..9622762 --- /dev/null +++ b/psf-memo-client/src/components/post-feed/like-result.js @@ -0,0 +1,40 @@ +/* + Broadcast result for a like/tip submission. + + Shown after a like is broadcast: the success message, the like transaction + id, and a link to that transaction on the block explorer. The link opens in + a new tab. + + Written in plain React.createElement style so the same module can be used by + the JSX components in the browser build and by the acceptance adapter that + renders HTML under Node. +*/ + +const React = require('react') + +function LikeResult ({ txid = '', message = '', explorerUrl = '' }) { + return React.createElement( + 'div', + { className: 'like-result' }, + React.createElement('p', { className: 'like-result-message' }, message), + txid + ? React.createElement( + 'p', + { className: 'like-result-txid mb-0' }, + 'Transaction ID: ', + React.createElement( + 'a', + { + href: explorerUrl, + target: '_blank', + rel: 'noopener noreferrer', + style: { wordBreak: 'break-all' } + }, + txid + ) + ) + : null + ) +} + +module.exports = LikeResult diff --git a/psf-memo-client/src/components/post-feed/like-tip-modal.js b/psf-memo-client/src/components/post-feed/like-tip-modal.js index 073463d..44bd3b7 100644 --- a/psf-memo-client/src/components/post-feed/like-tip-modal.js +++ b/psf-memo-client/src/components/post-feed/like-tip-modal.js @@ -12,6 +12,7 @@ import { Modal, Form, Button } from 'react-bootstrap' import MemoLike from '../../services/memo-like' import LikeTipPage from '../../services/like-tip-page' +import LikeResult from './like-result' import { getDisplayName } from './post-display' import './post-feed.css' @@ -24,6 +25,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) const [tip, setTip] = useState('') const [error, setError] = useState('') const [submitting, setSubmitting] = useState(false) + const [resultTxid, setResultTxid] = useState('') + const [showResult, setShowResult] = useState(false) const displayName = post ? getDisplayName(post.addr, profiles) : '' @@ -33,12 +36,16 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) setTip('') setError('') setSubmitting(false) + setResultTxid('') + setShowResult(false) return } setTip('') setError('') setSubmitting(false) + setResultTxid('') + setShowResult(false) let cancelled = false @@ -82,6 +89,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) const result = await page.submit() if (result.ok) { setTip('') + setResultTxid(result.txid) + setShowResult(true) if (typeof onSuccess === 'function') { onSuccess() } @@ -95,12 +104,18 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) } } - function handleCancel () { + function handleDismiss () { setTip('') setError('') + setResultTxid('') + setShowResult(false) onHide() } + function handleCancel () { + handleDismiss() + } + return ( @@ -108,47 +123,69 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) - {post && ( + {post && !showResult && (

Like the post by {displayName}

)} -
{ e.preventDefault(); handleSubmit() }}> - - Tip (satoshis, optional) - { - setTip(e.target.value) - // Clear a previous validation error so the user can retry. - setError('') - }} - disabled={submitting} + {showResult + ? ( + - -
+ ) + : ( + <> +
{ e.preventDefault(); handleSubmit() }}> + + Tip (satoshis, optional) + { + setTip(e.target.value) + // Clear a previous validation error so the user can retry. + setError('') + }} + disabled={submitting} + /> + +
- {error && ( -

{error}

- )} + {error && ( +

{error}

+ )} + + )}
- - + {showResult + ? ( + + ) + : ( + <> + + + + )}
) diff --git a/psf-memo-client/src/components/post-feed/post-feed-item.js b/psf-memo-client/src/components/post-feed/post-feed-item.js index 82a0f0b..76e1559 100644 --- a/psf-memo-client/src/components/post-feed/post-feed-item.js +++ b/psf-memo-client/src/components/post-feed/post-feed-item.js @@ -77,9 +77,10 @@ function PostFeedItem ({ } const handleLikeSuccess = () => { + // Reflect the like immediately. The modal stays open to show the + // broadcast result until the user dismisses it. setLiked(true) setLikeCount((count) => count + 1) - setShowLikeModal(false) } const handleLikeModalHide = () => { diff --git a/psf-memo-client/src/components/post-feed/post-feed.css b/psf-memo-client/src/components/post-feed/post-feed.css index e7d142d..6a04b3b 100644 --- a/psf-memo-client/src/components/post-feed/post-feed.css +++ b/psf-memo-client/src/components/post-feed/post-feed.css @@ -681,6 +681,18 @@ font-size: 0.95rem; } +.like-result { + text-align: left; +} + +.like-result-message { + margin-bottom: 0.75rem; +} + +.like-result-txid { + font-size: 0.9rem; +} + .posts-feed-item-youtube { position: relative; width: 100%; diff --git a/psf-memo-client/src/services/like-tip-page.js b/psf-memo-client/src/services/like-tip-page.js index 7a0c48c..1095360 100644 --- a/psf-memo-client/src/services/like-tip-page.js +++ b/psf-memo-client/src/services/like-tip-page.js @@ -15,6 +15,9 @@ const PageController = require('./page-controller') +const EXPLORER_TX_BASE = 'https://bch.loping.net/tx' +const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.' + class LikeTipPage extends PageController { constructor (deps = {}) { super(deps) @@ -25,6 +28,10 @@ class LikeTipPage extends PageController { this.authorAddress = deps.authorAddress || '' this.successPath = null this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance'] + // After a broadcast, the like/tip modal stays open and shows the result + // until the user dismisses it. + this.showResultModal = false + this.lastResult = null } // Open the like/tip modal for a post and check that the wallet has enough @@ -33,6 +40,8 @@ class LikeTipPage extends PageController { this.postTxid = postTxid this.authorAddress = authorAddress this.modalOpen = true + this.showResultModal = false + this.lastResult = null this.submitError = null this.broadcastError = null @@ -75,6 +84,38 @@ class LikeTipPage extends PageController { this.tipping = value } + // Submit the like. A successful broadcast leaves the modal open and shows + // the broadcast result instead of closing or navigating. Validation and + // broadcast failures stay on the form. + async submit () { + this.showResultModal = false + this.lastResult = null + const result = await super.submit() + this.lastResult = result + if (result.ok) { + this.showResultModal = true + this.modalOpen = true + } + return result + } + + // The broadcast success message shown while the result is visible. + getBroadcastMessage () { + if (!this.lastResult || !this.lastResult.ok) return '' + return SUCCESS_MESSAGE + } + + // Block explorer URL for a broadcast like transaction. + explorerUrl (txid) { + return LikeTipPage.explorerUrl(txid) + } + + // Dismiss the broadcast result. This closes the like/tip modal. + dismissResult () { + this.showResultModal = false + this.close() + } + // Parse a non-empty tip string into an integer number of satoshis. _parseTip (input) { if (input === '' || input === null || input === undefined) return 0 @@ -104,3 +145,10 @@ class LikeTipPage extends PageController { } module.exports = LikeTipPage + +LikeTipPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE +LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE +LikeTipPage.explorerUrl = function (txid) { + if (!txid) return '' + return `${EXPLORER_TX_BASE}/${txid}` +} diff --git a/psf-memo-client/test/unit/like-result.test.js b/psf-memo-client/test/unit/like-result.test.js new file mode 100644 index 0000000..ff964ea --- /dev/null +++ b/psf-memo-client/test/unit/like-result.test.js @@ -0,0 +1,59 @@ +/* + Unit tests for the like broadcast result component. + + The component shows the broadcast success message, the like transaction id, + and a link to that transaction on the block explorer that opens in a new tab. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const LikeResult = require('../../src/components/post-feed/like-result') + +const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111' +const MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.' +const EXPLORER_URL = `https://bch.loping.net/tx/${SAMPLE_TXID}` + +function renderResult (props = {}) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(LikeResult, { + txid: SAMPLE_TXID, + message: MESSAGE, + explorerUrl: EXPLORER_URL, + ...props + }) + ) +} + +test('renders the broadcast success message', () => { + const html = renderResult() + + assert.ok(html.includes(MESSAGE)) +}) + +test('renders the like transaction id', () => { + const html = renderResult() + + assert.ok(html.includes(SAMPLE_TXID)) +}) + +test('renders a link to the block explorer', () => { + const html = renderResult() + + assert.match(html, new RegExp(`href="${EXPLORER_URL}"`)) +}) + +test('the explorer link opens in a new tab', () => { + const html = renderResult() + + assert.match(html, /target="_blank"/) +}) + +test('renders no link without a transaction id', () => { + const html = renderResult({ txid: '' }) + + assert.ok(!html.includes(' { + const { page } = makePage() + + assert.equal(page.showResultModal, false) + assert.equal(page.lastResult, null) +}) + +test('explorerUrl builds a bch.loping.net transaction link', () => { + assert.equal( + LikeTipPage.explorerUrl(SAMPLE_TXID), + `https://bch.loping.net/tx/${SAMPLE_TXID}` + ) + assert.equal(LikeTipPage.explorerUrl(''), '') + + const { page } = makePage() + assert.equal(page.explorerUrl(SAMPLE_TXID), LikeTipPage.explorerUrl(SAMPLE_TXID)) +}) + +test('SUCCESS_MESSAGE announces the broadcast', () => { + assert.equal(typeof LikeTipPage.SUCCESS_MESSAGE, 'string') + assert.match(LikeTipPage.SUCCESS_MESSAGE, /broadcast/i) +}) + +test('a successful like keeps the modal open and shows the result', async () => { + const { page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(result.txid, LIKE_TXID) + assert.equal(page.modalOpen, true) + assert.equal(page.showResultModal, true) + assert.equal(page.lastResult.txid, LIKE_TXID) + assert.equal(page.getBroadcastMessage(), LikeTipPage.SUCCESS_MESSAGE) +}) + +test('dismissing the result closes the like/tip modal', async () => { + const { page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('') + await page.submit() + + page.dismissResult() + + assert.equal(page.showResultModal, false) + assert.equal(page.modalOpen, false) +}) + +test('opening the modal clears any previous result', async () => { + const { page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('') + await page.submit() + + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(page.showResultModal, false) + assert.equal(page.lastResult, null) +}) + +test('a validation failure stays on the form and opens no result', async () => { + const { page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('abc') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.equal(page.modalOpen, true) + assert.equal(page.showResultModal, false) + assert.equal(page.lastResult.ok, false) +}) + +test('a broadcast failure stays on the form and opens no result', async () => { + const { page } = makePage({ failWith: 'Insufficient balance' }) + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(page.broadcastError, /Insufficient balance/) + assert.equal(page.modalOpen, true) + assert.equal(page.showResultModal, false) + assert.equal(page.getBroadcastMessage(), '') +}) From e978516a0fc08d667ac47d973975b329adc08470 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 12:03:41 -0700 Subject: [PATCH 2/4] Refactor like result modal and share the block explorer link Deduplicate the new like-result acceptance steps behind one render helper, and extract the bch.loping.net transaction link shared by the New Post result modal, the post options menu, and the like/tip result into a block-explorer service. Cover the LikeTipPage open/parse/handler failure branches and add property tests for the explorer URL, the LikeResult markup, and the result modal state machine. CRAP is at or below 5 with full coverage on the changed services; every changed/new plain source file scans at 21 or fewer mutation sites. The existing mutation manifests are left for the mutation tool to refresh. By refactorer. --- psf-memo-client/acceptance/lib/handlers.js | 60 +++--- .../src/services/block-explorer.js | 20 ++ psf-memo-client/src/services/like-tip-page.js | 9 +- psf-memo-client/src/services/new-post.js | 9 +- psf-memo-client/src/services/post-options.js | 13 +- .../property/like-result.property.test.js | 176 ++++++++++++++++++ .../test/unit/block-explorer.test.js | 35 ++++ .../test/unit/like-tip-page.test.js | 52 ++++++ 8 files changed, 316 insertions(+), 58 deletions(-) create mode 100644 psf-memo-client/src/services/block-explorer.js create mode 100644 psf-memo-client/test/property/like-result.property.test.js create mode 100644 psf-memo-client/test/unit/block-explorer.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 3e49045..4c5fa34 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -1507,19 +1507,7 @@ const handlers = [ name: 'like/tip modal shows a broadcast success message', pattern: /^the like\/tip modal shows a broadcast success message$/, run (m, example, world) { - const page = world.likeTipPage - if (!page.showResultModal) { - throw new Error('Expected the like broadcast result to be shown.') - } - const message = page.getBroadcastMessage() - if (!message) { - throw new Error('Expected a like broadcast success message.') - } - const html = renderLikeResult({ - txid: page.lastResult.txid, - message, - explorerUrl: page.explorerUrl(page.lastResult.txid) - }) + const { message, html } = renderLikeBroadcastResult(world) if (!html.includes(message)) { throw new Error(`The rendered like result does not show the message "${message}".`) } @@ -1529,19 +1517,7 @@ const handlers = [ name: 'like/tip modal shows the like transaction id', pattern: /^the like\/tip modal shows the like transaction id$/, run (m, example, world) { - const page = world.likeTipPage - if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { - throw new Error('Expected a successful like broadcast result.') - } - const txid = page.lastResult.txid - if (!txid) { - throw new Error('Expected the like result to include a transaction id.') - } - const html = renderLikeResult({ - txid, - message: page.getBroadcastMessage(), - explorerUrl: page.explorerUrl(txid) - }) + const { txid, html } = renderLikeBroadcastResult(world) if (!html.includes(txid)) { throw new Error(`The rendered like result does not show the transaction id ${txid}.`) } @@ -1551,20 +1527,10 @@ const handlers = [ name: 'like/tip modal shows a block explorer link', pattern: /^the like\/tip modal shows a link to the block explorer for the like transaction$/, run (m, example, world) { - const page = world.likeTipPage - if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { - throw new Error('Expected a successful like broadcast result.') - } - const txid = page.lastResult.txid - const url = page.explorerUrl(txid) + const { url, html } = renderLikeBroadcastResult(world) if (!url.startsWith('https://bch.loping.net/tx/')) { throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`) } - const html = renderLikeResult({ - txid, - message: page.getBroadcastMessage(), - explorerUrl: url - }) if (!html.includes(`href="${url}"`)) { throw new Error(`The rendered like result does not link to ${url}.`) } @@ -3502,6 +3468,26 @@ function togglePostOptionsMenu (world, txid) { world.activeMenuTxid = txid } +// Require a successful like broadcast result and render it to static HTML for +// the like/tip acceptance assertions. The caller inspects the returned fields. +function renderLikeBroadcastResult (world) { + const page = world.likeTipPage + if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) { + throw new Error('Expected a successful like broadcast result.') + } + const txid = page.lastResult.txid + if (!txid) { + throw new Error('Expected the like result to include a transaction id.') + } + const message = page.getBroadcastMessage() + if (!message) { + throw new Error('Expected a like broadcast success message.') + } + const url = page.explorerUrl(txid) + const html = renderLikeResult({ txid, message, explorerUrl: url }) + return { txid, message, url, html } +} + // The posts currently rendered by the page the scenario has opened. function postsOnCurrentPage (world) { const path = world.currentPath || '' diff --git a/psf-memo-client/src/services/block-explorer.js b/psf-memo-client/src/services/block-explorer.js new file mode 100644 index 0000000..54ff984 --- /dev/null +++ b/psf-memo-client/src/services/block-explorer.js @@ -0,0 +1,20 @@ +/* + Block explorer link for a Bitcoin Cash transaction. + + Single source for the bch.loping.net transaction URL shared by the New Post + result modal, the post options menu, and the like/tip broadcast result, so + the base URL and link shape cannot drift between features. +*/ + +const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' + +// Block explorer URL for a transaction, or '' without a txid. +function blockExplorerTxUrl (txid) { + if (!txid) return '' + return `${BLOCK_EXPLORER_TX_BASE}/${txid}` +} + +module.exports = { + BLOCK_EXPLORER_TX_BASE, + blockExplorerTxUrl +} diff --git a/psf-memo-client/src/services/like-tip-page.js b/psf-memo-client/src/services/like-tip-page.js index 1095360..8789f75 100644 --- a/psf-memo-client/src/services/like-tip-page.js +++ b/psf-memo-client/src/services/like-tip-page.js @@ -14,8 +14,8 @@ */ const PageController = require('./page-controller') +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') -const EXPLORER_TX_BASE = 'https://bch.loping.net/tx' const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.' class LikeTipPage extends PageController { @@ -146,9 +146,6 @@ class LikeTipPage extends PageController { module.exports = LikeTipPage -LikeTipPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE +LikeTipPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE -LikeTipPage.explorerUrl = function (txid) { - if (!txid) return '' - return `${EXPLORER_TX_BASE}/${txid}` -} +LikeTipPage.explorerUrl = blockExplorerTxUrl diff --git a/psf-memo-client/src/services/new-post.js b/psf-memo-client/src/services/new-post.js index 19c292e..42b0c46 100644 --- a/psf-memo-client/src/services/new-post.js +++ b/psf-memo-client/src/services/new-post.js @@ -15,10 +15,10 @@ const PageController = require('./page-controller') const MemoPost = require('./memo-post') +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') const NEW_POST_PATH = '/posts/new' const RECENT_FEED_PATH = '/posts/recent' -const EXPLORER_TX_BASE = 'https://bch.loping.net/tx' class NewPostPage extends PageController { constructor (deps = {}) { @@ -95,11 +95,8 @@ class NewPostPage extends PageController { NewPostPage.NEW_POST_PATH = NEW_POST_PATH NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH -NewPostPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE -NewPostPage.explorerUrl = function (txid) { - if (!txid) return '' - return `${EXPLORER_TX_BASE}/${txid}` -} +NewPostPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE +NewPostPage.explorerUrl = blockExplorerTxUrl module.exports = NewPostPage diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js index 7489ea0..4bef6cf 100644 --- a/psf-memo-client/src/services/post-options.js +++ b/psf-memo-client/src/services/post-options.js @@ -11,14 +11,9 @@ by the browser components, by unit tests, and by the acceptance handlers. */ -const BLOCK_EXPLORER_LABEL = 'See on block explorer' -const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' +const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer') -// Block explorer URL for a post transaction, or '' without a txid. -function explorerTxUrl (txid) { - if (!txid) return '' - return `${BLOCK_EXPLORER_TX_BASE}/${txid}` -} +const BLOCK_EXPLORER_LABEL = 'See on block explorer' // The ordered menu items for a post. The block explorer link is first. function postOptionsItems (txid) { @@ -26,7 +21,7 @@ function postOptionsItems (txid) { { id: 'block-explorer', label: BLOCK_EXPLORER_LABEL, - href: explorerTxUrl(txid), + href: blockExplorerTxUrl(txid), target: '_blank', rel: 'noopener noreferrer' } @@ -99,7 +94,7 @@ function postOptionsKeyCommand (key, items = []) { module.exports = { BLOCK_EXPLORER_LABEL, BLOCK_EXPLORER_TX_BASE, - explorerTxUrl, + explorerTxUrl: blockExplorerTxUrl, postOptionsItems, initialPostOptionsState, openPostOptions, diff --git a/psf-memo-client/test/property/like-result.property.test.js b/psf-memo-client/test/property/like-result.property.test.js new file mode 100644 index 0000000..1d62b5d --- /dev/null +++ b/psf-memo-client/test/property/like-result.property.test.js @@ -0,0 +1,176 @@ +/* + Property tests for the like broadcast result. + + The unit tests probe the like result at a few fixed fixtures. These + properties pin down the invariants over broad random inputs: + + - explorerUrl composes the shared block explorer base with the txid and + returns '' for every falsy input. + - The LikeResult component always shows the message, shows the txid and an + explorer link that opens in a new tab exactly when a txid is present, and + is deterministic. + - The result-modal state machine mirrors the last submit outcome: a + successful like opens the result with the success message and keeps the + modal open; a failed like shows no result. Dismissing always closes the + result and the modal, and reopening always clears the previous result. +*/ + +'use strict' + +const test = require('node:test') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const { seededRandom, forAll, intGen } = require('./harness') +const LikeTipPage = require('../../src/services/like-tip-page') +const MemoLike = require('../../src/services/memo-like') +const LikeResult = require('../../src/components/post-feed/like-result') + +const rng = seededRandom(20260916) + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc' +const LIKE_TXID = 'ab'.repeat(32) + +const HEX = '0123456789abcdef' +const SAFE_WORDS = ['like', 'broadcast', 'success', 'memo', 'network', 'tip', 'post'] +const FALSY = [undefined, null, '', 0, false] + +function randomTxid () { + const n = intGen(rng, 1, 64)() + let out = '' + for (let i = 0; i < n; i++) out += HEX[Math.floor(rng() * HEX.length)] + return out +} + +function randomMessage () { + const n = intGen(rng, 1, 6)() + let out = '' + for (let i = 0; i < n; i++) { + out += `${SAFE_WORDS[intGen(rng, 0, SAFE_WORDS.length - 1)()]} ` + } + return out.trim() +} + +function makeWallet (failWith) { + return { + walletInfo: { cashAddress: MY_ADDRESS }, + utxos: [{ txid: 'utxo', value: 100000 }], + broadcasts: [], + async getUtxos () { + return this.utxos + }, + async sendOpReturn (msg, prefix, bchOutput = []) { + this.broadcasts.push({ msg, prefix, bchOutput }) + if (failWith) throw new Error(failWith) + return LIKE_TXID + } + } +} + +function makeSubmittedPage (fail) { + const memoLike = new MemoLike({ wallet: makeWallet(fail ? 'Insufficient balance' : null) }) + const page = new LikeTipPage({ memoLike }) + page.open(randomTxid(), AUTHOR_ADDRESS) + page.setTip('') + return page +} + +function render (props) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(LikeResult, props) + ) +} + +test('explorerUrl composes the shared base and the txid', async () => { + await forAll( + () => randomTxid(), + async (txid) => + LikeTipPage.explorerUrl(txid) === `${LikeTipPage.EXPLORER_TX_BASE}/${txid}`, + { label: 'like explorer url composition', samples: 2000 } + ) +}) + +test('explorerUrl returns an empty string for every falsy input', async () => { + await forAll( + () => FALSY[intGen(rng, 0, FALSY.length - 1)()], + async (value) => LikeTipPage.explorerUrl(value) === '', + { label: 'like explorer url falsy inputs', samples: 500 } + ) +}) + +test('LikeResult always shows the message and links only with a txid', async () => { + await forAll( + () => ({ txid: rng() < 0.2 ? '' : randomTxid(), message: randomMessage() }), + async ({ txid, message }) => { + const url = LikeTipPage.explorerUrl(txid) + const html = render({ txid, message, explorerUrl: url }) + if (!html.includes(message)) return false + if (txid) { + if (!html.includes(txid)) return false + if (!html.includes(`href="${url}"`)) return false + if (!html.includes('target="_blank"')) return false + } else if (html.includes(' { + await forAll( + () => ({ txid: randomTxid(), message: randomMessage() }), + async (props) => { + const first = render(props) + const second = render(props) + return first === second + }, + { label: 'like result render determinism', samples: 500 } + ) +}) + +test('the result modal mirrors the last submit outcome', async () => { + await forAll( + () => rng() < 0.4, + async (fail) => { + const page = makeSubmittedPage(fail) + const result = await page.submit() + if (result.ok) { + if (page.showResultModal !== true || page.modalOpen !== true) return false + if (page.getBroadcastMessage() !== LikeTipPage.SUCCESS_MESSAGE) return false + } else { + if (page.showResultModal !== false) return false + if (page.getBroadcastMessage() !== '') return false + } + return true + }, + { label: 'like result modal outcome', samples: 400 } + ) +}) + +test('dismissing the result always closes the result and the modal', async () => { + await forAll( + () => rng() < 0.5, + async (fail) => { + const page = makeSubmittedPage(fail) + await page.submit() + page.dismissResult() + return page.showResultModal === false && page.modalOpen === false + }, + { label: 'like result dismiss', samples: 400 } + ) +}) + +test('reopening the like/tip modal always clears the previous result', async () => { + await forAll( + () => rng() < 0.5, + async (fail) => { + const page = makeSubmittedPage(fail) + await page.submit() + page.open(randomTxid(), AUTHOR_ADDRESS) + return page.showResultModal === false && page.lastResult === null + }, + { label: 'like result reopen clears', samples: 400 } + ) +}) diff --git a/psf-memo-client/test/unit/block-explorer.test.js b/psf-memo-client/test/unit/block-explorer.test.js new file mode 100644 index 0000000..9eedfe2 --- /dev/null +++ b/psf-memo-client/test/unit/block-explorer.test.js @@ -0,0 +1,35 @@ +/* + Unit tests for the shared block explorer link. + + The New Post result modal, the post options menu, and the like/tip broadcast + result all use this module so the explorer base URL and link shape stay in + one place. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { + BLOCK_EXPLORER_TX_BASE, + blockExplorerTxUrl +} = require('../../src/services/block-explorer') + +const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111' + +test('the block explorer base points at bch.loping.net', () => { + assert.equal(BLOCK_EXPLORER_TX_BASE, 'https://bch.loping.net/tx') +}) + +test('blockExplorerTxUrl builds a transaction link', () => { + assert.equal( + blockExplorerTxUrl(SAMPLE_TXID), + `${BLOCK_EXPLORER_TX_BASE}/${SAMPLE_TXID}` + ) +}) + +test('blockExplorerTxUrl returns an empty string without a txid', () => { + assert.equal(blockExplorerTxUrl(''), '') + assert.equal(blockExplorerTxUrl(null), '') + assert.equal(blockExplorerTxUrl(undefined), '') +}) diff --git a/psf-memo-client/test/unit/like-tip-page.test.js b/psf-memo-client/test/unit/like-tip-page.test.js index 62de2bd..4017e17 100644 --- a/psf-memo-client/test/unit/like-tip-page.test.js +++ b/psf-memo-client/test/unit/like-tip-page.test.js @@ -132,3 +132,55 @@ test('a broadcast failure stays on the form and opens no result', async () => { assert.equal(page.showResultModal, false) assert.equal(page.getBroadcastMessage(), '') }) + +test('open reports a validation error without a memo like handler', () => { + const page = new LikeTipPage({}) + + const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_validation') + assert.match(result.message, /memo like handler/) + assert.equal(page.modalOpen, true) + assert.equal(page.showResultModal, false) +}) + +test('open reports an empty-balance error below the dust limit', () => { + const wallet = makeWallet() + wallet.utxos = [{ txid: 'utxo', value: 100 }] + const memoLike = new MemoLike({ wallet }) + const page = new LikeTipPage({ memoLike }) + + const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, false) + assert.equal(result.error, 'like_empty_balance') + assert.match(page.broadcastError, /add BCH/) + assert.equal(page.showResultModal, false) +}) + +test('submit fails without a memo like handler', async () => { + const page = new LikeTipPage({}) + page.setTip('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(page.broadcastError, /memo like handler/) + assert.equal(page.showResultModal, false) +}) + +test('a like with a positive tip sends the tip to the author', async () => { + const { wallet, page } = makePage() + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('600') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(wallet.broadcasts.length, 1) + assert.deepEqual(wallet.broadcasts[0].bchOutput, [ + { address: AUTHOR_ADDRESS, amountSat: 600 } + ]) +}) From e071881ea06f9a7951352e5242319d21f7ba7997 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 12:29:53 -0700 Subject: [PATCH 3/4] Harden like result modal: kill mutation survivors and DRY test setup Add unit coverage for the LikeTipPage initial state, deps seeding, the dust-limit boundary, and empty/null tip parsing, and assert exact validation/broadcast error messages. Add the NewPostPage initial result-modal state assertion. Refresh the mutate4javascript footer manifests for the touched services and the soft Gherkin acceptance-mutation manifest for the like-result feature. By architect. --- .../specs/like-broadcast-result.feature | 4 ++ .../src/components/post-feed/like-result.js | 4 ++ .../src/services/block-explorer.js | 4 ++ psf-memo-client/src/services/like-tip-page.js | 4 ++ psf-memo-client/src/services/new-post.js | 2 +- psf-memo-client/src/services/post-options.js | 2 +- .../test/unit/like-tip-page.test.js | 66 ++++++++++++++----- .../test/unit/new-post-page.test.js | 4 +- 8 files changed, 69 insertions(+), 21 deletions(-) diff --git a/psf-memo-client/specs/like-broadcast-result.feature b/psf-memo-client/specs/like-broadcast-result.feature index 6c24276..89afc09 100644 --- a/psf-memo-client/specs/like-broadcast-result.feature +++ b/psf-memo-client/specs/like-broadcast-result.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-16T19:27:51.931950466Z","feature_name":"Like Broadcast Result","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/like-broadcast-result.feature","background_hash":"1714896143ae0425b1c9938c1bc926ef61183ee3e842aa76d8bfd0ec751fcc9c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Like Broadcast Result - 1 a successful like keeps the modal open and shows the broadcast result","scenario_hash":"315b01b6a9c59504c9ecf91ec0229a957c7490387f69f246b33f899dd18a896f","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T19:27:51.931950466Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Like Broadcast Result - 1, Like Broadcast Result - 2, Like Broadcast Result - 3 # # After a like is broadcast from the like/tip modal, the modal no longer closes diff --git a/psf-memo-client/src/components/post-feed/like-result.js b/psf-memo-client/src/components/post-feed/like-result.js index 9622762..1f598fe 100644 --- a/psf-memo-client/src/components/post-feed/like-result.js +++ b/psf-memo-client/src/components/post-feed/like-result.js @@ -38,3 +38,7 @@ function LikeResult ({ txid = '', message = '', explorerUrl = '' }) { } module.exports = LikeResult + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T19:24:39.237Z","module_hash":"53beb37c8b7aaa5c0291ff89be8444acd53240d70817b691a8eeb7def5d072f2","functions":[{"id":"func/LikeResult","name":"LikeResult","line":15,"end_line":38,"hash":"a9094a5a6f71e1142eab902750c33dded00411ae9627158589831aa3567c96a8"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/block-explorer.js b/psf-memo-client/src/services/block-explorer.js index 54ff984..8d64abd 100644 --- a/psf-memo-client/src/services/block-explorer.js +++ b/psf-memo-client/src/services/block-explorer.js @@ -18,3 +18,7 @@ module.exports = { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T19:07:27.216Z","module_hash":"941a53501bc90a2ae36e1c0dc3890fd24add133e651c868a1a8f4f6a31035cb7","functions":[{"id":"func/blockExplorerTxUrl","name":"blockExplorerTxUrl","line":12,"end_line":15,"hash":"a8d84886234e25a946eb3363e23583b621851a2778990aa57df7d32fb8ed949a"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/like-tip-page.js b/psf-memo-client/src/services/like-tip-page.js index 8789f75..e6956fa 100644 --- a/psf-memo-client/src/services/like-tip-page.js +++ b/psf-memo-client/src/services/like-tip-page.js @@ -149,3 +149,7 @@ module.exports = LikeTipPage LikeTipPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE LikeTipPage.explorerUrl = blockExplorerTxUrl + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T19:21:51.249Z","module_hash":"df3f5a3cd43fba8d3b7267561b21c84b68b964a958b6b77f0fa4b12f3afffc8d","functions":[{"id":"func/LikeTipPage.constructor","name":"LikeTipPage.constructor","line":22,"end_line":35,"hash":"020fe0af9dd6885fb83ad2edc0e94ef5d28661eec2a138fa5576743014341124"},{"id":"func/LikeTipPage.open","name":"LikeTipPage.open","line":39,"end_line":66,"hash":"0bfb69e7e8f6d9fc8ec2c923c20578215e8765088c514f8398461e64108aaaff"},{"id":"func/LikeTipPage.close","name":"LikeTipPage.close","line":69,"end_line":74,"hash":"673843dc179c1a325e224d6ad235f945d14b7efbceefb26f1b1f69dccfa4e535"},{"id":"func/LikeTipPage.setTip","name":"LikeTipPage.setTip","line":78,"end_line":80,"hash":"cfa38ba8b9f799839393de98e83d5512699327d99dbcf3de11d322be3a027323"},{"id":"func/LikeTipPage._setBusy","name":"LikeTipPage._setBusy","line":83,"end_line":85,"hash":"8fe4b97a14cdbd3849bd6bf493738a2caf50f36002de1c96603290b98c83e511"},{"id":"func/LikeTipPage.submit","name":"LikeTipPage.submit","line":90,"end_line":100,"hash":"d7a255241f316400a9c1f30b057ae7c63e27421d76432c8e02ecc88121319982"},{"id":"func/LikeTipPage.getBroadcastMessage","name":"LikeTipPage.getBroadcastMessage","line":103,"end_line":106,"hash":"800d20ba3a50dcffcee063bc5cd6422b548713520530b3d159cf1980310000b2"},{"id":"func/LikeTipPage.explorerUrl","name":"LikeTipPage.explorerUrl","line":109,"end_line":111,"hash":"7822bf8055c5fc45eaa46919b8665c8954def38f92bed94b091796a6a9b5381d"},{"id":"func/LikeTipPage.dismissResult","name":"LikeTipPage.dismissResult","line":114,"end_line":117,"hash":"96d1421749b8e3a3c295ad071d4bd921d17edd7c5e8824b1b3c3196b009cfcab"},{"id":"func/LikeTipPage._parseTip","name":"LikeTipPage._parseTip","line":120,"end_line":128,"hash":"7db17db65fe02da8db7d4658aa2fc38d52f61754c6e3a92cca5db6ebcf8eda5a"},{"id":"func/LikeTipPage._perform","name":"LikeTipPage._perform","line":131,"end_line":137,"hash":"f6af26bbf3965455c3436dd1da28bf22e27b65d28d4e02a9fe423a909efca030"},{"id":"func/LikeTipPage._handleSubmitFailure","name":"LikeTipPage._handleSubmitFailure","line":141,"end_line":144,"hash":"a66731eb7050a3c87ff7d228de3661facb9c971e7611d7953f86ba1b88bb1110"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/new-post.js b/psf-memo-client/src/services/new-post.js index 42b0c46..748adea 100644 --- a/psf-memo-client/src/services/new-post.js +++ b/psf-memo-client/src/services/new-post.js @@ -101,5 +101,5 @@ NewPostPage.explorerUrl = blockExplorerTxUrl module.exports = NewPostPage // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-13T13:36:17.184Z","module_hash":"65edafa07e390d316197347119a4d2a50c3fe73980c8e526f38fa0f66ee69689","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":24,"end_line":37,"hash":"1a42004b33ac28f396df02f70b2cec6a91f890d3a065aefe890c23cb1456ace3"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":40,"end_line":43,"hash":"2bb99421f2cdbcf9a39f77244b250e4251b9bd3cc81a4f399589007f71486b79"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":46,"end_line":48,"hash":"f2e167f20bd3040ebbcfaf3a3e95254333b9739e0fa93d7801ead542be2a15e3"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":51,"end_line":53,"hash":"421d0af0e01278b479a5bb8d62d344498f875d188e8307eda420d637c23600d4"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":56,"end_line":58,"hash":"1552af7690ee7a92b2a9ed84af1ba301c2468939b6994ca3942fe0b7004028fb"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":61,"end_line":66,"hash":"c8b7394bcd5a2252278095e9af08cc5e76720b4fab08aa83218cb93fdb372407"},{"id":"func/NewPostPage.explorerUrl","name":"NewPostPage.explorerUrl","line":69,"end_line":71,"hash":"84a048fa78b38cef32573f1e31be11f870c765393213e59744d9799690da4d37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":75,"end_line":84,"hash":"1f15969b62987f29fbc310845716fe65c9ace8bec2f7bb2d8bee62e59b7cee95"},{"id":"func/NewPostPage.dismissResult","name":"NewPostPage.dismissResult","line":87,"end_line":93,"hash":"c155b35aedd68c7bbde87329ec086079621cf362ecf01472b65c7e442c9646b7"}]} +// {"version":1,"tested_at":"2026-09-16T19:19:23.763Z","module_hash":"a285c6a2a27006b4e90960ebfe9968651e6cfd7a4f12e600fca76d521eda2751","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":24,"end_line":37,"hash":"ea4edf077e9c3631366acadc62f27b8cf4d5087cc6d2e317db9086b1d0d18347"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":40,"end_line":43,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":46,"end_line":48,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":51,"end_line":53,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":56,"end_line":58,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":61,"end_line":66,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"},{"id":"func/NewPostPage.explorerUrl","name":"NewPostPage.explorerUrl","line":69,"end_line":71,"hash":"947125deea2b19ae2dff9d9bd9449a23f10b6bf6075a9c9be4bc588cd211ff56"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":75,"end_line":84,"hash":"56282a4dea0927a6ab8bd3e6452f1860b978bcf4598d17cdcf86f6083b561b17"},{"id":"func/NewPostPage.dismissResult","name":"NewPostPage.dismissResult","line":87,"end_line":93,"hash":"35cae53c6e851cd96e5b81dfe5a2b954f458bdacd60d8228676779c025820c73"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js index 4bef6cf..8500ff5 100644 --- a/psf-memo-client/src/services/post-options.js +++ b/psf-memo-client/src/services/post-options.js @@ -108,5 +108,5 @@ module.exports = { } // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-16T16:53:14.617Z","module_hash":"9564c40bbc416ef70c9b53f4dcdd849182f85734b9e48f90521b400921d3c6b3","functions":[{"id":"func/explorerTxUrl","name":"explorerTxUrl","line":18,"end_line":21,"hash":"b0284558de13bf4b63d70227a688add4739e577a8908593f3ced4ff557be6a16"},{"id":"func/postOptionsItems","name":"postOptionsItems","line":24,"end_line":34,"hash":"271f7b7971ae5d011c7014444b67d4e3f7cd25b85474f9a556e8bf8a8acb2b35"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":37,"end_line":39,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":42,"end_line":44,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":47,"end_line":49,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":52,"end_line":54,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":57,"end_line":60,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":63,"end_line":65,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":68,"end_line":70,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":76,"end_line":78,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":84,"end_line":97,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]} +// {"version":1,"tested_at":"2026-09-16T19:14:16.172Z","module_hash":"01043ede064dd77b6d74651fdbd61874be779a060c600d48c400d28194fc0348","functions":[{"id":"func/postOptionsItems","name":"postOptionsItems","line":19,"end_line":29,"hash":"a50c66c4fcc7255a2142df15b8714a7831c184b3fb58d09ce6c6c4795a031b5b"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":32,"end_line":34,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":37,"end_line":39,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":42,"end_line":44,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":47,"end_line":49,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":52,"end_line":55,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":58,"end_line":60,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":63,"end_line":65,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":71,"end_line":73,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":79,"end_line":92,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/test/unit/like-tip-page.test.js b/psf-memo-client/test/unit/like-tip-page.test.js index 4017e17..083e7ce 100644 --- a/psf-memo-client/test/unit/like-tip-page.test.js +++ b/psf-memo-client/test/unit/like-tip-page.test.js @@ -37,16 +37,40 @@ function makeWallet () { function makePage (opts = {}) { const wallet = makeWallet() + if (opts.balance !== undefined) { + wallet.utxos = [{ txid: 'utxo', value: opts.balance }] + } if (opts.failWith) wallet.failWith = opts.failWith const memoLike = new MemoLike({ wallet }) return { wallet, page: new LikeTipPage({ memoLike }) } } +// Open the modal, submit a like with no tip, and return the page and result. +async function submitLike (opts = {}) { + const { wallet, page } = makePage(opts) + page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + page.setTip('') + const result = await page.submit() + return { wallet, page, result } +} + test('the result modal starts hidden', () => { const { page } = makePage() assert.equal(page.showResultModal, false) assert.equal(page.lastResult, null) + assert.equal(page.modalOpen, false) + assert.equal(page.tipping, false) +}) + +test('the controller seeds the post txid and author from deps', () => { + const page = new LikeTipPage({ + postTxid: SAMPLE_TXID, + authorAddress: AUTHOR_ADDRESS + }) + + assert.equal(page.postTxid, SAMPLE_TXID) + assert.equal(page.authorAddress, AUTHOR_ADDRESS) }) test('explorerUrl builds a bch.loping.net transaction link', () => { @@ -66,11 +90,7 @@ test('SUCCESS_MESSAGE announces the broadcast', () => { }) test('a successful like keeps the modal open and shows the result', async () => { - const { page } = makePage() - page.open(SAMPLE_TXID, AUTHOR_ADDRESS) - page.setTip('') - - const result = await page.submit() + const { page, result } = await submitLike() assert.equal(result.ok, true) assert.equal(result.txid, LIKE_TXID) @@ -81,10 +101,7 @@ test('a successful like keeps the modal open and shows the result', async () => }) test('dismissing the result closes the like/tip modal', async () => { - const { page } = makePage() - page.open(SAMPLE_TXID, AUTHOR_ADDRESS) - page.setTip('') - await page.submit() + const { page } = await submitLike() page.dismissResult() @@ -93,10 +110,7 @@ test('dismissing the result closes the like/tip modal', async () => { }) test('opening the modal clears any previous result', async () => { - const { page } = makePage() - page.open(SAMPLE_TXID, AUTHOR_ADDRESS) - page.setTip('') - await page.submit() + const { page } = await submitLike() page.open(SAMPLE_TXID, AUTHOR_ADDRESS) @@ -113,6 +127,7 @@ test('a validation failure stays on the form and opens no result', async () => { assert.equal(result.ok, false) assert.equal(result.error, 'like_validation') + assert.equal(page.broadcastError, 'Tip must be a valid number of satoshis.') assert.equal(page.modalOpen, true) assert.equal(page.showResultModal, false) assert.equal(page.lastResult.ok, false) @@ -127,7 +142,7 @@ test('a broadcast failure stays on the form and opens no result', async () => { assert.equal(result.ok, false) assert.equal(result.error, 'broadcast') - assert.match(page.broadcastError, /Insufficient balance/) + assert.equal(page.broadcastError, 'Insufficient balance') assert.equal(page.modalOpen, true) assert.equal(page.showResultModal, false) assert.equal(page.getBroadcastMessage(), '') @@ -146,10 +161,7 @@ test('open reports a validation error without a memo like handler', () => { }) test('open reports an empty-balance error below the dust limit', () => { - const wallet = makeWallet() - wallet.utxos = [{ txid: 'utxo', value: 100 }] - const memoLike = new MemoLike({ wallet }) - const page = new LikeTipPage({ memoLike }) + const { page } = makePage({ balance: 100 }) const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) @@ -159,6 +171,16 @@ test('open reports an empty-balance error below the dust limit', () => { assert.equal(page.showResultModal, false) }) +test('open accepts a balance exactly at the dust limit', () => { + const { page } = makePage({ balance: 3000 }) + + const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS) + + assert.equal(result.ok, true) + assert.equal(page.submitError, null) + assert.equal(page.broadcastError, null) +}) + test('submit fails without a memo like handler', async () => { const page = new LikeTipPage({}) page.setTip('') @@ -184,3 +206,11 @@ test('a like with a positive tip sends the tip to the author', async () => { { address: AUTHOR_ADDRESS, amountSat: 600 } ]) }) + +test('an empty, null, or undefined tip parses as zero', () => { + const { page } = makePage() + + assert.equal(page._parseTip(''), 0) + assert.equal(page._parseTip(null), 0) + assert.equal(page._parseTip(undefined), 0) +}) diff --git a/psf-memo-client/test/unit/new-post-page.test.js b/psf-memo-client/test/unit/new-post-page.test.js index d1d6a04..cf739b2 100644 --- a/psf-memo-client/test/unit/new-post-page.test.js +++ b/psf-memo-client/test/unit/new-post-page.test.js @@ -38,10 +38,12 @@ function makeMemoPost (opts = {}) { return { wallet, feed, memoPost: new MemoPost({ wallet, feed }) } } -test('the in-flight flag starts false', () => { +test('the in-flight and result-modal flags start false', () => { const page = new NewPostPage({ navigate: () => {} }) assert.equal(page.posting, false) + assert.equal(page.showResultModal, false) + assert.equal(page.lastResult, null) }) test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => { From bf7edd50fc7b5b60883207b98d07debac9a9f42c Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 16 Sep 2026 12:32:40 -0700 Subject: [PATCH 4/4] Record like-result-modal review and verification By architect. --- docs/architect-process-notes.md | 15 ++ docs/reviews/like-result-modal-summary.md | 149 ++++++++++++++++++ .../like-result-modal-verification.json | 46 ++++++ 3 files changed, 210 insertions(+) create mode 100644 docs/reviews/like-result-modal-summary.md create mode 100644 docs/reviews/like-result-modal-verification.json diff --git a/docs/architect-process-notes.md b/docs/architect-process-notes.md index 7c0aca5..795b4cb 100644 --- a/docs/architect-process-notes.md +++ b/docs/architect-process-notes.md @@ -18,6 +18,21 @@ distinct from per-task verification results, which live in ## Tooling behavior / runtime +- **Pure modules can legitimately report 0 mutation sites.** `mutate4javascript` + only targets arithmetic, comparison, equality, boolean, logical, and `0<->1` + constant sites. A module built from `!` guards, ternaries, and template + literals (e.g. `block-explorer.js`, `like-result.js`) scans as `Total mutation + sites: 0` with `Killed: 0, Survived: 0`. Run `--scan` to confirm the zero is + structural and not a skipped/under-selected run before treating it as a pass. + +- **Run `dry4javascript` scoped to the changed files/dirs, not the whole client.** + A broad `src test acceptance` run reports hundreds of pre-existing duplicate + blocks (475 in the client on 2026-09-16) — mostly `acceptance/lib/handlers.js` + step-handler boilerplate and repeated older-suite test setup — which buries the + one or two task-local candidates. Scope the run to the changed production + files, tests, and adapters; the broad run is only a noise floor, consistent + with prior reviews. + - **Bare `dry4javascript` runs the full test suite (~1m50s).** It is a DRY analysis that invokes tests, so running it with no arguments is a slow full-suite run, not a fast readiness probe. Never use it as a startup smoke diff --git a/docs/reviews/like-result-modal-summary.md b/docs/reviews/like-result-modal-summary.md new file mode 100644 index 0000000..9132a8c --- /dev/null +++ b/docs/reviews/like-result-modal-summary.md @@ -0,0 +1,149 @@ +# like-result-modal — Architect Review + +Task: `like-result-modal` +Component: `psf-memo-client` +Base: `3486da3` (last merged architect review); inbound refactorer commit `e978516` + +## What was reviewed + +Inbound refactorer batch (priority 50), merged onto `swarmforge-architect` by +fast-forwarding `3486da3` -> `e978516`. The linear chain reviewed: + +- **`efe7a6c`** — specifier: *Record post-options-menu completion in backlog and briefing*. +- **`3af0d42`** — specifier: *Add like broadcast result specification*. Adds + `psf-memo-client/specs/like-broadcast-result.feature` (3 scenario outlines). +- **`a0040f9`** — coder: *Implement like broadcast result modal*. Keeps the + like/tip modal open after a successful like and shows the success message, the + like txid, and a block-explorer link. Adds the pure `LikeTipPage` result state, + the presentational `like-result.js`, the acceptance render adapter, step + handlers, and unit tests. +- **`e978516`** — refactorer: *Refactor like result modal and share the block + explorer link*. Extracts `src/services/block-explorer.js` as the single source + of the `bch.loping.net/tx` URL shared by the New Post result modal, the post + options menu, and the like/tip result; DRYs the new acceptance steps behind + `renderLikeBroadcastResult`; adds property tests. + +**Architect review commit: `e071881ea0`** — the `mutate4javascript` footer +manifests, the soft `gherkin-mutator` acceptance-mutation manifest stamp, and the +hardening changes below. The summary and verification record are committed on +top, so `git diff e071881ea0 HEAD` touches only `docs/`. The record's `git_sha` +is `e071881ea0`, the commit that contains the verified source state. + +## Architectural findings and fixes applied + +The refactorer's structure is sound: a framework-free controller, a +server-renderable presentational component, a pure shared URL module, and a +separate acceptance render adapter. The hardening work was mutation- and +duplication-driven. + +1. **UI/Core separation.** `src/services/block-explorer.js` is a pure leaf + (`if (!txid) return ''` + a template literal) with no React, DOM, or IO. + `LikeTipPage` owns the broadcast-result state machine and is exercised with no + browser; `LikeResult` only maps props to markup and is reached by the + acceptance adapter through `acceptance/lib/render-like-result.js`, which + server-renders the actual component. +2. **Dependency rule.** `new-post.js`, `post-options.js`, and `like-tip-page.js` + depend inward on `block-explorer.js`; nothing in the pure modules reaches out + to React, the router, or the wallet. No framework or persistence structure + leaks across the boundary. +3. **Information hiding / DRY.** The block-explorer base URL and link shape now + have exactly one definition. The old `EXPLORER_TX_BASE` literals in + `new-post.js`, `post-options.js`, and `like-tip-page.js` are gone; the + `EXPLORER_TX_BASE`/`explorerUrl`/`explorerTxUrl` names remain as thin aliases + for existing acceptance/test callers. +4. **Mutation survivors (like-tip-page).** Initial run: **13 killed, 8 survived, + 0 uncovered** across 21 sites. Every survivor was a missing assertion, not a + design fault: + - the `false` initial `tipping`/`modalOpen`, and the `deps.postTxid`/ + `deps.authorAddress` constructor seeds, were never asserted; + - `open`'s `<` dust comparison had no boundary case (balance exactly at 3000); + - the `open` success result's `true` was unchecked; + - `_parseTip`'s second `||` (null/undefined) was uncovered; + - `_handleSubmitFailure`'s `err.message || String(err)` survived because a + *broadcast* failure is re-derived by the superclass, so the value is only + observable on a *validation* failure. + Added unit coverage for each; re-run: **21 killed, 0 survived, 0 uncovered**. +5. **Mutation survivor (new-post).** The `NewPostPage` constructor's + `this.showResultModal = false` (line 32) was a pre-existing survivor. Added an + initial-state assertion; re-run: **10 killed, 0 survived, 0 uncovered**. +6. **Test duplication.** The first DRY pass flagged two structurally identical + test pairs in `like-tip-page.test.js`. Extracted a `submitLike()` helper and a + `balance` option on `makePage()`; the remaining pair (dismiss vs. reopen) is + semantically distinct and is left as structural test boilerplate. +7. **Result-modal controller duplication (documented, not changed).** + `LikeTipPage` mirrors the `showResultModal`/`lastResult`/`submit`/ + `dismissResult` shape of `NewPostPage`, with a different show/dismiss policy. + A shared result-modal controller would touch both controllers, their unit + tests, and acceptance handlers; per `docs/architect-process-notes.md` that is + a broad cross-module refactor beyond a single review, so it is recorded as a + follow-up candidate rather than folded into this task. Likewise, the React + `like-tip-modal.js` keeps its own transient `showResult`/`resultTxid` state; + it is the environmentally unsuitable shell that node unit and acceptance + mutation deliberately do not target, matching the existing component pattern. + +## Verification results + +### Language mutation (`mutate4javascript`, `--mutate-all`, `--max-workers 8`) + +| File | Sites | Killed | Survived | Uncovered | +|------|------:|-------:|---------:|----------:| +| `src/services/like-tip-page.js` | 21 | 21 | 0 | 0 | +| `src/services/new-post.js` | 10 | 10 | 0 | 0 | +| `src/services/post-options.js` | 10 | 10 | 0 | 0 | +| `src/services/block-explorer.js` | 0 | 0 | 0 | 0 | +| `src/components/post-feed/like-result.js` | 0 | 0 | 0 | 0 | + +`block-explorer.js` and `like-result.js` contain only `!`, a ternary, and a +template literal — constructs `mutate4javascript` does not target — so `--scan` +reports 0 sites; this is structural, not a skipped run. The two JSX/ESM +components (`like-tip-modal.js`, `post-feed-item.js`) are outside the node unit +test boundary and are covered end-to-end by acceptance. + +### DRY (`dry4javascript`) + +Focused run over the changed source, tests, and adapters: the only task-local +duplicate left is the semantically distinct dismiss/reopen test pair noted above. +A broad run over `src test acceptance` reports 475 blocks, all pre-existing +`acceptance/lib/handlers.js` step-handler boilerplate and repeated test setup +across older suites — none involves the new production modules. + +### CRAP / cyclomatic complexity (`crap4javascript`) + +All changed functions at or below the 8.0 threshold with **100% coverage**, e.g. +`LikeTipPage._parseTip` (CC 5, CRAP 5.0), `LikeTipPage.open` (CC 4, CRAP 4.0), +`LikeTipPage.getBroadcastMessage` (CC 3, CRAP 3.0), `NewPostPage.submit`/ +`dismissResult` (CC 3, CRAP 3.0), `blockExplorerTxUrl` (CC 2, CRAP 2.0), +`LikeResult` (CC 2, CRAP 2.0). + +### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`) + +`like-broadcast-result.feature`: **7 executed, 4 killed, 3 survived, 0 errors**. +The three survivors are consistent-value intrinsic equivalents — each mutated +example value is used consistently on both the setup and assertion sides: + +- `tip: 600 -> 601` and `tip: 25000 -> 25007` (Scenario 2): the tip is entered + and then asserted against the same `` parameter. +- Scenario 3 `liked_txid` with an injected `x`: Scenario 3's purpose is the + dismiss-closes-modal behavior; it reuses `` on the setup and click + steps but makes no txid-validity or broadcast assertion (Scenarios 1 and 2, + which do assert the broadcast, killed the same class of mutation). + +The tool stamped Scenario 0 (all mutations killed) and left Scenarios 1–2 +unstamped, as designed. + +### Suite status + +`swarmforge/scripts/verify.sh client --record +docs/reviews/like-result-modal-verification.json --task like-result-modal` +-> **pass (5/5)**: +unit **387 pass / 0 fail**, property **75 pass / 0 fail**, acceptance **all 29 +suites passed**, lint **ok**, build **ok**. Record `git_sha` = `e071881ea0`. + +## Handoffs sent + +- End-of-chain `git_handoff` to the specifier (task `like-result-modal`) with the + review commit so it can merge `swarmforge-architect` into `master`. +- No coder/refactorer handoff: the review is test hardening plus manifest refresh + with no follow-up work for those roles. + +By architect. diff --git a/docs/reviews/like-result-modal-verification.json b/docs/reviews/like-result-modal-verification.json new file mode 100644 index 0000000..4ad4f40 --- /dev/null +++ b/docs/reviews/like-result-modal-verification.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "task": "like-result-modal", + "component": "psf-memo-client", + "git_sha": "e071881ea06f9a7951352e5242319d21f7ba7997", + "branch": "swarmforge-architect", + "timestamp": "2026-09-16T19:31:39.772Z", + "commands": [ + { + "name": "unit", + "command": "npm test", + "exit": 0, + "duration_ms": 8374, + "summary": "387 pass / 0 fail" + }, + { + "name": "property", + "command": "npm run test:property", + "exit": 0, + "duration_ms": 9402, + "summary": "75 pass / 0 fail" + }, + { + "name": "acceptance", + "command": "npm run test:acceptance", + "exit": 0, + "duration_ms": 13394, + "summary": "all 29 acceptance suites passed" + }, + { + "name": "lint", + "command": "npm run lint", + "exit": 0, + "duration_ms": 2266, + "summary": "ok" + }, + { + "name": "build", + "command": "npm run build", + "exit": 0, + "duration_ms": 66200, + "summary": "ok" + } + ], + "result": "pass" +}