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