mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Refactor mute broadcast result: share result handlers, add property tests
Deduplicate the like/mute acceptance broadcast-result assertion handlers and the follow/unfollow/mute/unmute prefix handlers behind shared helpers, and extract the broadcast explorer transaction link into one component used by both result components. Add property tests for the mute result component and the profile page mute result state machine (explorer URL composition, error vs success markup, determinism, outcome mirroring, failure state preservation, and dismiss). CRAP stays at or below 6, every changed source module stays below 100 mutation sites, and mutation manifests are left intact for differential runs. By refactorer.
This commit is contained in:
@@ -1686,36 +1686,21 @@ 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 { message, html } = renderLikeBroadcastResult(world)
|
||||
if (!html.includes(message)) {
|
||||
throw new Error(`The rendered like result does not show the message "${message}".`)
|
||||
}
|
||||
assertBroadcastResultMessage(world, 'like')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal shows the like transaction id',
|
||||
pattern: /^the like\/tip modal shows the like transaction id$/,
|
||||
run (m, example, world) {
|
||||
const { txid, html } = renderLikeBroadcastResult(world)
|
||||
if (!html.includes(txid)) {
|
||||
throw new Error(`The rendered like result does not show the transaction id ${txid}.`)
|
||||
}
|
||||
assertBroadcastResultTxid(world, 'like')
|
||||
}
|
||||
},
|
||||
{
|
||||
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 { url, html } = renderLikeBroadcastResult(world)
|
||||
if (!url.startsWith('https://bch.loping.net/tx/')) {
|
||||
throw new Error(`Expected a bch.loping.net explorer link, got "${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.')
|
||||
}
|
||||
assertBroadcastResultExplorerLink(world, 'like')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1977,38 +1962,14 @@ const handlers = [
|
||||
name: 'broadcasts OP_RETURN with Memo follow prefix for address',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_FOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo follow prefix ${MEMO_FOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast follow hash160 did not match ${addr}.`)
|
||||
}
|
||||
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_FOLLOW_PREFIX, 'follow')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo unfollow prefix for address',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_UNFOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo unfollow prefix ${MEMO_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast unfollow hash160 did not match ${addr}.`)
|
||||
}
|
||||
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNFOLLOW_PREFIX, 'unfollow')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2089,38 +2050,14 @@ const handlers = [
|
||||
name: 'broadcasts or attempts Memo mute prefix for address',
|
||||
pattern: /^the app (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo mute prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_MUTE_PREFIX) {
|
||||
throw new Error(`Expected Memo mute prefix ${MEMO_MUTE_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast mute hash160 did not match ${addr}.`)
|
||||
}
|
||||
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_MUTE_PREFIX, 'mute')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo unmute prefix for address',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unmute prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_UNMUTE_PREFIX) {
|
||||
throw new Error(`Expected Memo unmute prefix ${MEMO_UNMUTE_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast unmute hash160 did not match ${addr}.`)
|
||||
}
|
||||
assertMemoBroadcastPrefix(world, resolveParam(m[1], example), MEMO_UNMUTE_PREFIX, 'unmute')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2139,36 +2076,21 @@ const handlers = [
|
||||
name: 'mute result modal shows a broadcast success message',
|
||||
pattern: /^the mute result modal shows a broadcast success message$/,
|
||||
run (m, example, world) {
|
||||
const { message, html } = renderMuteBroadcastResult(world)
|
||||
if (!html.includes(message)) {
|
||||
throw new Error(`The rendered mute result does not show the message "${message}".`)
|
||||
}
|
||||
assertBroadcastResultMessage(world, 'mute')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mute result modal shows the mute transaction id',
|
||||
pattern: /^the mute result modal shows the mute transaction id$/,
|
||||
run (m, example, world) {
|
||||
const { txid, html } = renderMuteBroadcastResult(world)
|
||||
if (!html.includes(txid)) {
|
||||
throw new Error(`The rendered mute result does not show the transaction id ${txid}.`)
|
||||
}
|
||||
assertBroadcastResultTxid(world, 'mute')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mute result modal shows a block explorer link',
|
||||
pattern: /^the mute result modal shows a link to the block explorer for the mute transaction$/,
|
||||
run (m, example, world) {
|
||||
const { url, html } = renderMuteBroadcastResult(world)
|
||||
if (!url.startsWith(`${ProfilePage.EXPLORER_TX_BASE}/`)) {
|
||||
throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`)
|
||||
}
|
||||
if (!html.includes(`href="${url}"`)) {
|
||||
throw new Error(`The rendered mute result does not link to ${url}.`)
|
||||
}
|
||||
if (!html.includes('target="_blank"')) {
|
||||
throw new Error('The rendered mute explorer link does not open in a new tab.')
|
||||
}
|
||||
assertBroadcastResultExplorerLink(world, 'mute')
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -4027,44 +3949,93 @@ 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.')
|
||||
// Broadcast result plumbing shared by the like/tip and mute acceptance
|
||||
// assertions. Each feature names the world field holding its page, the page
|
||||
// fields/getters for the visible result, and the component renderer.
|
||||
const BROADCAST_RESULTS = {
|
||||
like: {
|
||||
page: (world) => world.likeTipPage,
|
||||
showField: 'showResultModal',
|
||||
resultField: 'lastResult',
|
||||
getMessage: (page) => page.getBroadcastMessage(),
|
||||
render: renderLikeResult
|
||||
},
|
||||
mute: {
|
||||
page: (world) => world.profilePage,
|
||||
showField: 'showMuteResultModal',
|
||||
resultField: 'lastMuteResult',
|
||||
getMessage: (page) => page.getMuteBroadcastMessage(),
|
||||
render: renderMuteResult
|
||||
}
|
||||
const txid = page.lastResult.txid
|
||||
}
|
||||
|
||||
// Require a successful broadcast result for `feature` and render it to static
|
||||
// HTML for the acceptance assertions. The caller inspects the returned fields.
|
||||
function renderBroadcastResult (world, feature) {
|
||||
const spec = BROADCAST_RESULTS[feature]
|
||||
const page = spec.page(world)
|
||||
if (!page || !page[spec.showField] || !page[spec.resultField] || !page[spec.resultField].ok) {
|
||||
throw new Error(`Expected a successful ${feature} broadcast result.`)
|
||||
}
|
||||
const txid = page[spec.resultField].txid
|
||||
if (!txid) {
|
||||
throw new Error('Expected the like result to include a transaction id.')
|
||||
throw new Error(`Expected the ${feature} result to include a transaction id.`)
|
||||
}
|
||||
const message = page.getBroadcastMessage()
|
||||
const message = spec.getMessage(page)
|
||||
if (!message) {
|
||||
throw new Error('Expected a like broadcast success message.')
|
||||
throw new Error(`Expected a ${feature} broadcast success message.`)
|
||||
}
|
||||
const url = page.explorerUrl(txid)
|
||||
const html = renderLikeResult({ txid, message, explorerUrl: url })
|
||||
const html = spec.render({ txid, message, explorerUrl: url })
|
||||
return { txid, message, url, html }
|
||||
}
|
||||
|
||||
// Require a successful mute broadcast result and render it to static HTML for
|
||||
// the mute acceptance assertions. The caller inspects the returned fields.
|
||||
function renderMuteBroadcastResult (world) {
|
||||
const page = world.profilePage
|
||||
if (!page || !page.showMuteResultModal || !page.lastMuteResult || !page.lastMuteResult.ok) {
|
||||
throw new Error('Expected a successful mute broadcast result.')
|
||||
// Assert the rendered broadcast result shows its success message.
|
||||
function assertBroadcastResultMessage (world, feature) {
|
||||
const { message, html } = renderBroadcastResult(world, feature)
|
||||
if (!html.includes(message)) {
|
||||
throw new Error(`The rendered ${feature} result does not show the message "${message}".`)
|
||||
}
|
||||
const txid = page.lastMuteResult.txid
|
||||
if (!txid) {
|
||||
throw new Error('Expected the mute result to include a transaction id.')
|
||||
}
|
||||
|
||||
// Assert the rendered broadcast result shows its transaction id.
|
||||
function assertBroadcastResultTxid (world, feature) {
|
||||
const { txid, html } = renderBroadcastResult(world, feature)
|
||||
if (!html.includes(txid)) {
|
||||
throw new Error(`The rendered ${feature} result does not show the transaction id ${txid}.`)
|
||||
}
|
||||
const message = page.getMuteBroadcastMessage()
|
||||
if (!message) {
|
||||
throw new Error('Expected a mute broadcast success message.')
|
||||
}
|
||||
|
||||
// Assert the rendered broadcast result links to the block explorer in a new
|
||||
// tab.
|
||||
function assertBroadcastResultExplorerLink (world, feature) {
|
||||
const { url, html } = renderBroadcastResult(world, feature)
|
||||
if (!url.startsWith(`${ProfilePage.EXPLORER_TX_BASE}/`)) {
|
||||
throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`)
|
||||
}
|
||||
if (!html.includes(`href="${url}"`)) {
|
||||
throw new Error(`The rendered ${feature} result does not link to ${url}.`)
|
||||
}
|
||||
if (!html.includes('target="_blank"')) {
|
||||
throw new Error(`The rendered ${feature} explorer link does not open in a new tab.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Assert that the wallet's most recent broadcast carries the Memo `label`
|
||||
// prefix and the 20-byte hash160 payload for `addr`.
|
||||
function assertMemoBroadcastPrefix (world, addr, prefix, label) {
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== prefix) {
|
||||
throw new Error(`Expected Memo ${label} prefix ${prefix}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast ${label} hash160 did not match ${addr}.`)
|
||||
}
|
||||
const url = page.explorerUrl(txid)
|
||||
const html = renderMuteResult({ txid, message, explorerUrl: url })
|
||||
return { txid, message, url, html }
|
||||
}
|
||||
|
||||
// The posts currently rendered by the page the scenario has opened.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
const React = require('react')
|
||||
const ExplorerTxLink = require('../../explorer-tx-link')
|
||||
|
||||
function MuteResult ({ txid = '', message = '', error = '', explorerUrl = '' }) {
|
||||
return React.createElement(
|
||||
@@ -23,23 +24,11 @@ function MuteResult ({ txid = '', message = '', error = '', explorerUrl = '' })
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement('p', { className: 'mute-result-message' }, message),
|
||||
txid
|
||||
? React.createElement(
|
||||
'p',
|
||||
{ className: 'mute-result-txid mb-0' },
|
||||
'Transaction ID: ',
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: explorerUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: { wordBreak: 'break-all' }
|
||||
},
|
||||
txid
|
||||
)
|
||||
)
|
||||
: null
|
||||
React.createElement(ExplorerTxLink, {
|
||||
txid,
|
||||
explorerUrl,
|
||||
className: 'mute-result-txid'
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Explorer link for a broadcast transaction.
|
||||
|
||||
Shown by the like/tip and mute broadcast result components: "Transaction ID:"
|
||||
followed by the txid linked to its block explorer page, opening in a new tab.
|
||||
The `className` is supplied by the caller so each result keeps its own
|
||||
styling.
|
||||
|
||||
Written in plain React.createElement style so the same module can be used by
|
||||
the JSX components in the browser build and by the Node acceptance adapters.
|
||||
*/
|
||||
|
||||
const React = require('react')
|
||||
|
||||
function ExplorerTxLink ({ txid = '', explorerUrl = '', className = '' }) {
|
||||
if (!txid) return null
|
||||
return React.createElement(
|
||||
'p',
|
||||
{ className: `${className} mb-0`.trim() },
|
||||
'Transaction ID: ',
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: explorerUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: { wordBreak: 'break-all' }
|
||||
},
|
||||
txid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = ExplorerTxLink
|
||||
@@ -11,29 +11,18 @@
|
||||
*/
|
||||
|
||||
const React = require('react')
|
||||
const ExplorerTxLink = require('../explorer-tx-link')
|
||||
|
||||
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
|
||||
React.createElement(ExplorerTxLink, {
|
||||
txid,
|
||||
explorerUrl,
|
||||
className: 'like-result-txid'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
Property tests for the mute broadcast result.
|
||||
|
||||
The unit tests probe the mute 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 MuteResult component always shows the message, shows the txid and an
|
||||
explorer link that opens in a new tab exactly when a txid is present,
|
||||
shows only the error when an error is present, and is deterministic.
|
||||
- The profile page mute result state machine mirrors the last broadcast: a
|
||||
successful mute/unmute opens the modal with the matching success message,
|
||||
a failure opens the failure modal with the error and leaves the button
|
||||
state unchanged, a new broadcast replaces the previous result, and
|
||||
dismissing closes the modal without changing the button.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
const MuteResult = require('../../src/components/app-body/profile/mute-result')
|
||||
|
||||
const rng = seededRandom(20260918)
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const MUTE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const MUTE_TXID = 'ab'.repeat(32)
|
||||
|
||||
const HEX = '0123456789abcdef'
|
||||
const SAFE_WORDS = ['mute', 'unmute', 'broadcast', 'success', 'memo', 'network']
|
||||
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 makeMemoDb () {
|
||||
return {
|
||||
async getPostsByAddr () {
|
||||
return { posts: [], pagination: { total: 0 } }
|
||||
},
|
||||
async getFollowState () {
|
||||
return false
|
||||
},
|
||||
async getMuteState () {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A profile page whose memo mute handler either succeeds with MUTE_TXID or
|
||||
// fails with the given message.
|
||||
function makePage ({ fail = null, txid = MUTE_TXID } = {}) {
|
||||
const page = new ProfilePage({ memoDb: makeMemoDb(), addr: MUTE_ADDRESS, myAddr: MY_ADDRESS })
|
||||
page.memoMute = {
|
||||
async mute () {
|
||||
if (fail) throw new Error(fail)
|
||||
return txid
|
||||
},
|
||||
async unmute () {
|
||||
if (fail) throw new Error(fail)
|
||||
return txid
|
||||
}
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
function render (props) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(MuteResult, props)
|
||||
)
|
||||
}
|
||||
|
||||
test('explorerUrl composes the shared base and the txid', async () => {
|
||||
await forAll(
|
||||
() => randomTxid(),
|
||||
async (txid) =>
|
||||
ProfilePage.explorerUrl(txid) === `${ProfilePage.EXPLORER_TX_BASE}/${txid}`,
|
||||
{ label: 'mute 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) => ProfilePage.explorerUrl(value) === '',
|
||||
{ label: 'mute explorer url falsy inputs', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('MuteResult shows the message and links only with a txid', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: rng() < 0.2 ? '' : randomTxid(), message: randomMessage() }),
|
||||
async ({ txid, message }) => {
|
||||
const url = ProfilePage.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: 'mute result markup', samples: 1500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('MuteResult shows only the error when an error is present', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(), message: randomMessage(), error: randomMessage() }),
|
||||
async ({ txid, message, error }) => {
|
||||
const html = render({ txid, message, error, explorerUrl: ProfilePage.explorerUrl(txid) })
|
||||
return html.includes('mute-result-error') &&
|
||||
html.includes(error) &&
|
||||
!html.includes('mute-result-message') &&
|
||||
!html.includes('<a ')
|
||||
},
|
||||
{ label: 'mute result error markup', samples: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('rendering the same MuteResult 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: 'mute result render determinism', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('the mute result modal mirrors the last broadcast outcome', async () => {
|
||||
await forAll(
|
||||
() => ({ method: rng() < 0.5 ? 'mute' : 'unmute', fail: rng() < 0.4 }),
|
||||
async ({ method, fail }) => {
|
||||
const page = makePage({ fail: fail ? 'Insufficient balance' : null })
|
||||
const result = await page[method]()
|
||||
|
||||
if (!page.showMuteResultModal) return false
|
||||
if (result.ok) {
|
||||
const expected = method === 'unmute' ? ProfilePage.UNMUTE_SUCCESS_MESSAGE : ProfilePage.MUTE_SUCCESS_MESSAGE
|
||||
return page.getMuteBroadcastMessage() === expected && page.getMuteResultError() === ''
|
||||
}
|
||||
return page.getMuteBroadcastMessage() === '' && page.getMuteResultError() === 'Insufficient balance'
|
||||
},
|
||||
{ label: 'mute result modal outcome', samples: 800 }
|
||||
)
|
||||
})
|
||||
|
||||
test('a failed mute broadcast leaves the button state unchanged', async () => {
|
||||
await forAll(
|
||||
() => ({ method: rng() < 0.5 ? 'mute' : 'unmute', initial: rng() < 0.5 }),
|
||||
async ({ method, initial }) => {
|
||||
const page = makePage({ fail: 'Insufficient balance' })
|
||||
page.muteState = initial
|
||||
const result = await page[method]()
|
||||
return result.ok === false && page.isMuting() === initial
|
||||
},
|
||||
{ label: 'mute failure leaves button state', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('a successful mute then unmute round-trips the reflected mute state', async () => {
|
||||
await forAll(
|
||||
() => rng() < 0.5,
|
||||
async (startMuted) => {
|
||||
const page = makePage()
|
||||
page.muteState = startMuted
|
||||
const muteResult = await page.mute()
|
||||
const afterMute = page.isMuting()
|
||||
const unmuteResult = await page.unmute()
|
||||
const afterUnmute = page.isMuting()
|
||||
return muteResult.ok && afterMute === true && unmuteResult.ok && afterUnmute === false
|
||||
},
|
||||
{ label: 'mute reflected state round trip', samples: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
test('a new broadcast replaces the previous result', async () => {
|
||||
await forAll(
|
||||
() => rng() < 0.5,
|
||||
async (firstFails) => {
|
||||
const page = makePage({ fail: firstFails ? 'Insufficient balance' : null })
|
||||
await page.mute()
|
||||
const before = { ok: page.lastMuteResult.ok, error: page.getMuteResultError() }
|
||||
// Force the opposite outcome on the next broadcast.
|
||||
page.memoMute = {
|
||||
async mute () {
|
||||
if (!firstFails) throw new Error('Insufficient balance')
|
||||
return MUTE_TXID
|
||||
},
|
||||
async unmute () {
|
||||
return MUTE_TXID
|
||||
}
|
||||
}
|
||||
await page.mute()
|
||||
return before.ok === !firstFails &&
|
||||
page.lastMuteResult.ok === firstFails &&
|
||||
page.getMuteResultError() === (firstFails ? '' : 'Insufficient balance')
|
||||
},
|
||||
{ label: 'mute result replace', samples: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
test('dismissing the mute result closes the modal without changing the button', async () => {
|
||||
await forAll(
|
||||
() => ({ method: rng() < 0.5 ? 'mute' : 'unmute', fail: rng() < 0.5 }),
|
||||
async ({ method, fail }) => {
|
||||
const page = makePage({ fail: fail ? 'Insufficient balance' : null })
|
||||
const result = await page[method]()
|
||||
const stateBeforeDismiss = page.muteState
|
||||
page.dismissMuteResult()
|
||||
return result.ok === !fail &&
|
||||
page.showMuteResultModal === false &&
|
||||
page.muteState === stateBeforeDismiss
|
||||
},
|
||||
{ label: 'mute result dismiss', samples: 500 }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user