Merge like-result-modal from swarmforge-architect

By specifier.
This commit is contained in:
Chris Troutner
2026-09-16 12:33:44 -07:00
19 changed files with 1005 additions and 51 deletions
+15
View File
@@ -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
+149
View File
@@ -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 `<tip>` parameter.
- Scenario 3 `liked_txid` with an injected `x`: Scenario 3's purpose is the
dismiss-closes-modal behavior; it reuses `<liked_txid>` 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 12
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.
@@ -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"
}
@@ -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,58 @@ 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 { message, html } = renderLikeBroadcastResult(world)
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 { txid, html } = renderLikeBroadcastResult(world)
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 { 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.')
}
}
},
{
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 (.+)$/,
@@ -3415,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 @@
/*
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 }
@@ -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
@@ -0,0 +1,44 @@
/*
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
// 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
@@ -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 (
<Modal show={show} onHide={handleCancel} centered>
<Modal.Header closeButton>
@@ -108,47 +123,69 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
</Modal.Header>
<Modal.Body>
{post && (
{post && !showResult && (
<p className='like-tip-modal-target'>
Like the post by <strong>{displayName}</strong>
</p>
)}
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
<Form.Group controlId='like-tip-amount' className='mb-3'>
<Form.Label>Tip (satoshis, optional)</Form.Label>
<Form.Control
type='number'
min='0'
step='1'
placeholder='0'
value={tip}
onChange={(e) => {
setTip(e.target.value)
// Clear a previous validation error so the user can retry.
setError('')
}}
disabled={submitting}
{showResult
? (
<LikeResult
txid={resultTxid}
message={LikeTipPage.SUCCESS_MESSAGE}
explorerUrl={LikeTipPage.explorerUrl(resultTxid)}
/>
</Form.Group>
</Form>
)
: (
<>
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
<Form.Group controlId='like-tip-amount' className='mb-3'>
<Form.Label>Tip (satoshis, optional)</Form.Label>
<Form.Control
type='number'
min='0'
step='1'
placeholder='0'
value={tip}
onChange={(e) => {
setTip(e.target.value)
// Clear a previous validation error so the user can retry.
setError('')
}}
disabled={submitting}
/>
</Form.Group>
</Form>
{error && (
<p className='like-tip-modal-error text-danger'>{error}</p>
)}
{error && (
<p className='like-tip-modal-error text-danger'>{error}</p>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant='secondary' onClick={handleCancel}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={submitting || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
{showResult
? (
<Button variant='primary' onClick={handleDismiss}>
Close
</Button>
)
: (
<>
<Button variant='secondary' onClick={handleCancel}>
Cancel
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={submitting || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
</>
)}
</Modal.Footer>
</Modal>
)
@@ -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 = () => {
@@ -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%;
@@ -0,0 +1,24 @@
/*
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
}
// 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
@@ -14,6 +14,9 @@
*/
const PageController = require('./page-controller')
const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer')
const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.'
class LikeTipPage extends PageController {
constructor (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,11 @@ class LikeTipPage extends PageController {
}
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
+4 -7
View File
@@ -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,14 +95,11 @@ 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
// 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
+5 -10
View File
@@ -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,
@@ -113,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
@@ -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), '')
})
@@ -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('<a '))
})
@@ -0,0 +1,216 @@
/*
Unit tests for the like/tip page controller's broadcast result.
After a like is broadcast, the like/tip modal no longer closes
automatically. The controller records the successful result and keeps the
modal open so the modal can show a broadcast result (message, txid, and
explorer link). Dismissing the result closes the modal.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoLike = require('../../src/services/memo-like')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111'
const LIKE_TXID = 'ab'.repeat(32)
function makeWallet () {
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 (this.failWith) throw new Error(this.failWith)
return LIKE_TXID
}
}
}
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', () => {
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, result } = await submitLike()
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 } = await submitLike()
page.dismissResult()
assert.equal(page.showResultModal, false)
assert.equal(page.modalOpen, false)
})
test('opening the modal clears any previous result', async () => {
const { page } = await submitLike()
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.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)
})
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.equal(page.broadcastError, 'Insufficient balance')
assert.equal(page.modalOpen, true)
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 { page } = makePage({ balance: 100 })
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('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('')
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 }
])
})
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)
})
@@ -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', () => {