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.
This commit is contained in:
Chris Troutner
2026-09-16 12:03:41 -07:00
parent a0040f98c1
commit e978516a0f
8 changed files with 316 additions and 58 deletions
@@ -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('<a ')) {
return false
}
return true
},
{ label: 'like result markup', samples: 1500 }
)
})
test('rendering the same LikeResult props twice yields the same markup', async () => {
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 }
)
})
@@ -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), '')
})
@@ -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 }
])
})