mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge post-options-menu from swarmforge-architect
By specifier.
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# post-options-menu — Architect Review
|
||||
|
||||
Task: `post-options-menu`
|
||||
Component: `psf-memo-client`
|
||||
Base: `5e62d1e` (last merged architect review); inbound refactorer commit `d0882bb`
|
||||
|
||||
## What was reviewed
|
||||
|
||||
Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by
|
||||
fast-forwarding to `d0882bb`. The linear chain reviewed:
|
||||
|
||||
- **`091272d`** — specifier: *Record feed-total-cap completion in backlog and briefing*.
|
||||
- **`bc964fa`** — specifier: *Specify post options menu for all post cards*. Adds
|
||||
`psf-memo-client/specs/post-options-menu.feature` (9 scenario outlines across the
|
||||
recent/following/topic/thread/profile surfaces) and the backlog entry.
|
||||
- **`20e67da`** — coder: *Implement post options menu on every post card*. Adds the
|
||||
pure `src/services/post-options.js`, the `src/components/post-feed/post-options-menu.js`
|
||||
shared React component, the acceptance render adapter, the post-options acceptance
|
||||
handlers, and unit tests; wires the menu into `post-feed-item.js` and `profile/index.js`.
|
||||
- **`d0882bb`** — refactorer: *Refactor post options menu and add property tests*.
|
||||
Moves the key-command mapping into `postOptionsKeyCommand` in the pure service so the
|
||||
component is a thin adapter, DRYs the duplicated acceptance step bodies behind
|
||||
`togglePostOptionsMenu` / `transitionActivePostOptionsMenu`, and adds
|
||||
`test/property/post-options.property.test.js`.
|
||||
|
||||
**Architect review commit: `07ff61392b`** — the two `mutate4javascript` manifest
|
||||
footers, the soft `gherkin-mutator` acceptance-mutation manifest stamp on
|
||||
`post-options-menu.feature`, and the hardening changes described below. The
|
||||
summary and verification record are committed on top, so
|
||||
`git diff 07ff61392b HEAD` touches only `docs/`. The record's `git_sha` is
|
||||
`07ff61392b`, the commit that contains the verified source state.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
|
||||
The refactorer's structure is sound: a framework-free core service, a React
|
||||
adapter, and a separate acceptance render adapter. Two testability gaps found
|
||||
by language mutation were fixed with a behavior-preserving extraction.
|
||||
|
||||
1. **UI/Core separation.** `src/services/post-options.js` is plain CommonJS with
|
||||
no React, DOM, or IO; it owns URL construction and the open/close/focus
|
||||
transitions. The React component consumes it through `require` and only maps
|
||||
state to markup. The acceptance pipeline reaches the same service through
|
||||
`acceptance/lib/render-post-options.js`, which server-renders the actual
|
||||
component. Core behavior is exercised with no browser.
|
||||
2. **Dependency rule.** The service depends on nothing; the component and the
|
||||
acceptance adapter depend inward on the service. No framework or persistence
|
||||
structure leaks across the boundary.
|
||||
3. **Uncovered mutation / testable boundary.** The outside-click decision was an
|
||||
inline `containerRef.current && !containerRef.current.contains(event.target)`
|
||||
inside `useEffect`, which the unit (`node --test`) harness never executes, so
|
||||
the `&& -> ||` mutation was **uncovered**. Extracted it to the pure
|
||||
`isOutsidePostOptions(container, target)` in the service; the effect is now a
|
||||
thin adapter and the decision is unit tested. This follows the role rule to
|
||||
maximize testable modules and minimize the environmentally unsuitable shell.
|
||||
4. **Mutation survivors.** Two `-1` default-focus literals survived because no
|
||||
unit test rendered an *open* menu with the default (unfocused) state: the
|
||||
`initialFocusedIndex = -1` default and the `tabIndex ... : -1` else branch.
|
||||
Added a unit test asserting the default open menu makes no item tabbable,
|
||||
killing both. (Property tests are a separate suite and are intentionally not
|
||||
part of the language-mutation test command.)
|
||||
5. **Duplication.** The refactorer's acceptance-step DRY is local and clear; the
|
||||
new service helper adds no duplication. `dry4javascript` reported 96
|
||||
duplicate blocks, all pre-existing `acceptance/lib/handlers.js`
|
||||
step-handler boilerplate; none involves the post-options modules.
|
||||
6. **Test/helper separation.** `render-post-options.js` (acceptance helper) and
|
||||
the property-test generators live in their helper libraries, separate from
|
||||
the unit and property test files.
|
||||
|
||||
## Verification results
|
||||
|
||||
### Language mutation (`mutate4javascript`, differential, `--max-workers 8`)
|
||||
|
||||
Initial component run before the fix: **3 killed, 2 survived, 1 uncovered**.
|
||||
After the extraction and unit tests:
|
||||
|
||||
- **`src/services/post-options.js`**: 10 covered sites. Differential selected
|
||||
1 of 10 after the new function; `mutate-file.sh` auto-reran `--mutate-all`:
|
||||
**10 killed, 0 survived, 0 uncovered**.
|
||||
- **`src/components/post-feed/post-options-menu.js`**: **5 killed, 0 survived,
|
||||
0 uncovered**.
|
||||
|
||||
### DRY (`dry4javascript`)
|
||||
|
||||
Changed source/tests/handlers plus `acceptance/lib/handlers.js`:
|
||||
**no duplicate candidate involves the new code**. 96 reported blocks are
|
||||
pre-existing handler step-handler boilerplate, consistent with prior reviews.
|
||||
|
||||
### CRAP / cyclomatic complexity (`crap4javascript`)
|
||||
|
||||
All changed functions below the 8.0 threshold:
|
||||
`PostOptionsMenu` (CC 3, 81.8% covered, CRAP 3.1 — the uncovered remainder is the
|
||||
DOM `useEffect` shell), `postOptionsKeyCommand` (CC 3, CRAP 3.0),
|
||||
`isOutsidePostOptions` (CC 2, 100%, CRAP 2.0), and the remaining service
|
||||
accessors (CC 1–2, 100%).
|
||||
|
||||
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
|
||||
|
||||
- **`post-options-menu.feature`**: **20 executed, 20 killed, 0 survived,
|
||||
0 errors**. All nine scenario outlines killed every soft mutation, so the tool
|
||||
wrote a full-feature acceptance-mutation manifest stamp (no survivors to
|
||||
document).
|
||||
|
||||
### Suite status
|
||||
|
||||
`swarmforge/scripts/verify.sh client --record
|
||||
docs/reviews/post-options-menu-verification.json --task post-options-menu`
|
||||
-> **pass (5/5)**:
|
||||
unit **364 passing**, property **68 passing**, acceptance **all 28 suites
|
||||
passing**, lint **pass**, build **pass**. Record `git_sha` = `07ff61392b`.
|
||||
|
||||
## Handoffs sent
|
||||
- End-of-chain `git_handoff` to the specifier (task `post-options-menu`) with the
|
||||
review commit so it can merge `swarmforge-architect` into `master`.
|
||||
- No coder/refactorer handoff: the review is test hardening plus a
|
||||
behavior-preserving extraction with no follow-up work for them.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task": "post-options-menu",
|
||||
"component": "psf-memo-client",
|
||||
"git_sha": "07ff61392b345547d320e71fd01594367afc1125",
|
||||
"branch": "swarmforge-architect",
|
||||
"timestamp": "2026-09-16T17:04:09.878Z",
|
||||
"commands": [
|
||||
{
|
||||
"name": "unit",
|
||||
"command": "npm test",
|
||||
"exit": 0,
|
||||
"duration_ms": 8834,
|
||||
"summary": "364 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "property",
|
||||
"command": "npm run test:property",
|
||||
"exit": 0,
|
||||
"duration_ms": 9064,
|
||||
"summary": "68 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "acceptance",
|
||||
"command": "npm run test:acceptance",
|
||||
"exit": 0,
|
||||
"duration_ms": 13642,
|
||||
"summary": "all 28 acceptance suites passed"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": "npm run lint",
|
||||
"exit": 0,
|
||||
"duration_ms": 2283,
|
||||
"summary": "ok"
|
||||
},
|
||||
{
|
||||
"name": "build",
|
||||
"command": "npm run build",
|
||||
"exit": 0,
|
||||
"duration_ms": 64231,
|
||||
"summary": "ok"
|
||||
}
|
||||
],
|
||||
"result": "pass"
|
||||
}
|
||||
@@ -54,6 +54,8 @@ const MemoPollVote = require('../../src/services/memo-poll-vote')
|
||||
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 PostOptions = require('../../src/services/post-options')
|
||||
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
@@ -3260,9 +3262,173 @@ const handlers = [
|
||||
renderPostText(post.text, { initialFailedImages: [...world.failedImages] })
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'page shows a post options button for the post',
|
||||
pattern: /^the page shows a post options button for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const post = findPostOnCurrentPage(txid, world)
|
||||
if (!post) {
|
||||
throw new Error(`The displayed page does not show a post with txid ${txid}.`)
|
||||
}
|
||||
const menu = getPostOptionsMenu(world, txid)
|
||||
const html = renderPostOptions(txid, { open: menu.open })
|
||||
if (!html.includes('aria-label="Post options"')) {
|
||||
throw new Error(`Post ${txid} does not render a post options button.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post options menu is hidden',
|
||||
pattern: /^the post options menu is hidden for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const menu = getPostOptionsMenu(world, txid)
|
||||
if (menu.open) {
|
||||
throw new Error(`Post options menu for ${txid} is open, but it should be hidden.`)
|
||||
}
|
||||
const html = renderPostOptions(txid, { open: menu.open })
|
||||
if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
|
||||
throw new Error(`Post options menu for ${txid} renders items while hidden.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click post options button',
|
||||
pattern: /^I click the post options button for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
togglePostOptionsMenu(world, resolveParam(m[1], example))
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click post options button again',
|
||||
pattern: /^I click the post options button again for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
togglePostOptionsMenu(world, resolveParam(m[1], example))
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post options menu is shown',
|
||||
pattern: /^the post options menu is shown for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const menu = getPostOptionsMenu(world, txid)
|
||||
if (!menu.open) {
|
||||
throw new Error(`Post options menu for ${txid} is hidden, but it should be shown.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'first post options menu item label',
|
||||
pattern: /^the first item in the post options menu is "(.+)"$/,
|
||||
run (m, example, world) {
|
||||
const label = m[1]
|
||||
const items = PostOptions.postOptionsItems(world.activeMenuTxid)
|
||||
if (!items[0] || items[0].label !== label) {
|
||||
throw new Error(`Expected the first post options item to be "${label}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'first post options menu item links in new tab',
|
||||
pattern: /^the first post options menu item links to (.+) and opens in a new tab$/,
|
||||
run (m, example, world) {
|
||||
const href = resolveParam(m[1], example)
|
||||
const txid = world.activeMenuTxid
|
||||
const first = PostOptions.postOptionsItems(txid)[0]
|
||||
if (!first || first.href !== href) {
|
||||
throw new Error(`Expected the first post options item to link to ${href}.`)
|
||||
}
|
||||
if (first.target !== '_blank') {
|
||||
throw new Error('Expected the first post options item to open in a new tab.')
|
||||
}
|
||||
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
|
||||
if (!html.includes(`href="${href}"`) || !html.includes('target="_blank"')) {
|
||||
throw new Error(`The rendered post options menu does not link to ${href} in a new tab.`)
|
||||
}
|
||||
if (!html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) {
|
||||
throw new Error('The rendered post options menu does not show the block explorer item.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click outside post options menu',
|
||||
pattern: /^I click outside the post options menu$/,
|
||||
run (m, example, world) {
|
||||
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'press Escape key',
|
||||
pattern: /^I press the Escape key$/,
|
||||
run (m, example, world) {
|
||||
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'press ArrowDown key',
|
||||
pattern: /^I press the ArrowDown key$/,
|
||||
run (m, example, world) {
|
||||
const txid = world.activeMenuTxid
|
||||
transitionActivePostOptionsMenu(world, (menu) =>
|
||||
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'first post options menu item has focus',
|
||||
pattern: /^the first post options menu item has focus$/,
|
||||
run (m, example, world) {
|
||||
const txid = world.activeMenuTxid
|
||||
const menu = getPostOptionsMenu(world, txid)
|
||||
if (menu.focusedIndex !== 0) {
|
||||
throw new Error('Expected the first post options item to have focus.')
|
||||
}
|
||||
const html = renderPostOptions(txid, { open: true, focusedIndex: 0 })
|
||||
if (!html.includes('tabindex="0"')) {
|
||||
throw new Error('The rendered first post options item is not focusable.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Find the post options menu state for a txid, creating a closed one on first use.
|
||||
function getPostOptionsMenu (world, txid) {
|
||||
if (!world.postOptionsMenus) world.postOptionsMenus = {}
|
||||
if (!world.postOptionsMenus[txid]) {
|
||||
world.postOptionsMenus[txid] = { txid, ...PostOptions.initialPostOptionsState() }
|
||||
}
|
||||
return world.postOptionsMenus[txid]
|
||||
}
|
||||
|
||||
// Apply a post options state transition to the active menu.
|
||||
function transitionActivePostOptionsMenu (world, transition) {
|
||||
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
|
||||
Object.assign(menu, transition(menu))
|
||||
}
|
||||
|
||||
// Toggle a post's options menu and make it the active menu.
|
||||
function togglePostOptionsMenu (world, txid) {
|
||||
const menu = getPostOptionsMenu(world, txid)
|
||||
Object.assign(menu, PostOptions.togglePostOptions(menu))
|
||||
world.activeMenuTxid = txid
|
||||
}
|
||||
|
||||
// The posts currently rendered by the page the scenario has opened.
|
||||
function postsOnCurrentPage (world) {
|
||||
const path = world.currentPath || ''
|
||||
if (world.threadPage && world.threadPage.rootPost) return world.threadPage.allPosts || []
|
||||
if (path.startsWith(ProfilePage.PROFILE_PATH_PREFIX)) return world.profilePage?.posts || []
|
||||
if (path.startsWith('/topics/')) return world.topicFeedPage?.posts || []
|
||||
if (path === FollowingFeedPage.FOLLOWING_FEED_PATH) return world.followingFeedPage?.posts || []
|
||||
return world.recentFeedPage?.posts || []
|
||||
}
|
||||
|
||||
function findPostOnCurrentPage (txid, world) {
|
||||
return postsOnCurrentPage(world).find((post) => post && post.txid === txid) || null
|
||||
}
|
||||
|
||||
// Return the cached rendered feed HTML, computing it on first use.
|
||||
function getRenderedFeed (world) {
|
||||
if (!world.renderedFeed) {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Acceptance rendering adapter for the shared post options menu.
|
||||
|
||||
Renders the same PostOptionsMenu component the browser uses to a static HTML
|
||||
string, so acceptance assertions can inspect the button and menu markup
|
||||
without running a browser. The component's open/closed and focused state can
|
||||
be seeded through props for deterministic rendering.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
|
||||
|
||||
function renderPostOptions (txid, options = {}) {
|
||||
const element = React.createElement(PostOptionsMenu, {
|
||||
txid,
|
||||
initialOpen: options.open,
|
||||
initialFocusedIndex: options.focusedIndex
|
||||
})
|
||||
return ReactDOMServer.renderToStaticMarkup(element)
|
||||
}
|
||||
|
||||
module.exports = { renderPostOptions }
|
||||
@@ -1,3 +1,8 @@
|
||||
# mutation-stamp: sha256=d6155399b60e3d5fda876a8b4dbc415d8b564bf233a6d61c67c8beae57ebc1da
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-09-16T16:59:26.923749533Z","feature_name":"Post Options Menu","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/post-options-menu.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Post Options Menu - 1 the post menu shows the block explorer link as its first item","scenario_hash":"9a82c0e05992fc1a2ad3e57acfe4796434a95310511dac60cb18de613be13be0","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":1,"name":"Post Options Menu - 2 clicking the post options button again closes the menu","scenario_hash":"723bdf16c5aee245b0e331a72ddbbc590fce23e8d9f46b232cc30658541873fc","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":2,"name":"Post Options Menu - 3 clicking outside the menu closes it","scenario_hash":"3cfd93550ed4b484efbd6c0572cbd6efaf61b3554a3e163e5aef34f43d4f1297","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":3,"name":"Post Options Menu - 4 pressing Escape closes the menu","scenario_hash":"109c8bd553e858e63d39639230981f1714fa0cff71c2abbcfeea69047aebbb12","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":4,"name":"Post Options Menu - 5 arrow keys move focus to the menu's first item","scenario_hash":"4f2c2c2f368c06fb399f4082398c3b41e423b46e55477b82296cedfe9c8a1f35","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":5,"name":"Post Options Menu - 6 the following feed post menu offers the block explorer link","scenario_hash":"49a9c0201d5a42ecbafaef4c4460248d1bf67218d7f81ea8cd5c00ac0ed0c57c","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":6,"name":"Post Options Menu - 7 the topic feed post menu offers the block explorer link","scenario_hash":"e96efacd24018eb02f348b53bae27e3d374969b692775f563e5c004ca1b90904","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":7,"name":"Post Options Menu - 8 the thread post menu offers the block explorer link","scenario_hash":"afc34b9045b83690e14e50c2850788787984f0c72324b54aa994ccc319af58d5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":8,"name":"Post Options Menu - 9 the profile post menu offers the block explorer link","scenario_hash":"7b253454da281aa3447d1b1121f9aec8bb9d4efacfb98b61a1232ee9d039c253","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Post Options Menu - 1, Post Options Menu - 2, Post Options Menu - 3, Post Options Menu - 4, Post Options Menu - 5, Post Options Menu - 6, Post Options Menu - 7, Post Options Menu - 8, Post Options Menu - 9
|
||||
#
|
||||
# Every post card has a "Post options" button (three dots) in its upper-right
|
||||
|
||||
@@ -344,6 +344,80 @@ header,
|
||||
border-top-color: var(--border);
|
||||
}
|
||||
|
||||
/* Post options menu */
|
||||
|
||||
.post-options {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.post-options-button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-round);
|
||||
box-shadow: none;
|
||||
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-options-button:hover,
|
||||
.post-options-button:focus-visible {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-muted);
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.post-options-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
|
||||
min-width: 200px;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
list-style: none;
|
||||
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
box-shadow: var(--shadow-menu);
|
||||
}
|
||||
|
||||
.post-options-menu-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.post-options-link {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-options-link:hover,
|
||||
.post-options-link:focus {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* General page headings */
|
||||
|
||||
.main-content h1 {
|
||||
|
||||
@@ -14,6 +14,7 @@ import ProfilePage from '../../../services/profile-page'
|
||||
import { getViewerAddress } from '../../../services/profile-wallet'
|
||||
import PostReplyCount from '../../post-reply-count'
|
||||
import LikeButton from '../../post-feed/like-button'
|
||||
import PostOptionsMenu from '../../post-feed/post-options-menu'
|
||||
import PostThreadModal from '../../post-thread-modal'
|
||||
import '../../../App.css'
|
||||
import './profile.css'
|
||||
@@ -248,9 +249,12 @@ function Profile (props) {
|
||||
{posts.map((post) => (
|
||||
<Card key={post.txid} className='profile-post-card mb-3'>
|
||||
<Card.Body>
|
||||
<div className='profile-post-meta text-muted mb-2'>
|
||||
<span>{formatSeen(post.seen)}</span>
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
<div className='profile-post-meta d-flex justify-content-between align-items-start text-muted mb-2'>
|
||||
<div>
|
||||
<span>{formatSeen(post.seen)}</span>
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
</div>
|
||||
<PostOptionsMenu txid={post.txid} />
|
||||
</div>
|
||||
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
|
||||
<div className='profile-post-actions d-flex gap-3 align-items-center'>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
truncateTxid
|
||||
} from './post-display'
|
||||
import PostContent from './post-content'
|
||||
import PostOptionsMenu from './post-options-menu'
|
||||
import './post-feed.css'
|
||||
|
||||
const appUtil = new AppUtil()
|
||||
@@ -131,14 +132,7 @@ function PostFeedItem ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type='button'
|
||||
className='posts-feed-item-menu'
|
||||
aria-label='Post options'
|
||||
title='Post options'
|
||||
>
|
||||
<span aria-hidden='true'>•••</span>
|
||||
</button>
|
||||
<PostOptionsMenu txid={post.txid} />
|
||||
</header>
|
||||
|
||||
<div className='posts-feed-item-content'>
|
||||
|
||||
@@ -499,30 +499,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.posts-feed-item-menu {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
|
||||
color: #262626;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.posts-feed-item-menu:hover {
|
||||
color: #737373;
|
||||
background: #f2f2f2;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.posts-feed-item-content {
|
||||
padding: 14px 16px 6px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Shared post options menu.
|
||||
|
||||
Every post card renders this same three-dots menu: a "Post options" button
|
||||
whose menu's first item is "See on block explorer", a link to the post
|
||||
transaction on bch.loping.net that opens in a new tab. The menu closes on the
|
||||
button again, an outside click, or Escape, and ArrowDown moves focus to the
|
||||
first item.
|
||||
|
||||
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')
|
||||
const {
|
||||
postOptionsItems,
|
||||
togglePostOptions,
|
||||
handlePostOptionsOutsideClick,
|
||||
isOutsidePostOptions,
|
||||
postOptionsKeyCommand
|
||||
} = require('../../services/post-options')
|
||||
|
||||
function PostOptionsMenu ({
|
||||
txid,
|
||||
initialOpen = false,
|
||||
initialFocusedIndex = -1
|
||||
}) {
|
||||
const [state, setState] = React.useState(() => ({
|
||||
open: Boolean(initialOpen),
|
||||
focusedIndex: initialFocusedIndex
|
||||
}))
|
||||
const containerRef = React.useRef(null)
|
||||
const items = postOptionsItems(txid)
|
||||
|
||||
// Close the open menu when the user clicks anywhere outside it.
|
||||
React.useEffect(() => {
|
||||
if (!state.open) return undefined
|
||||
|
||||
const handleDocumentMouseDown = (event) => {
|
||||
if (isOutsidePostOptions(containerRef.current, event.target)) {
|
||||
setState((previous) => handlePostOptionsOutsideClick(previous))
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleDocumentMouseDown)
|
||||
return () => document.removeEventListener('mousedown', handleDocumentMouseDown)
|
||||
}, [state.open])
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
const command = postOptionsKeyCommand(event.key, items)
|
||||
if (!command) return
|
||||
|
||||
if (command.preventDefault) event.preventDefault()
|
||||
setState((previous) => command.transition(previous))
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{
|
||||
className: 'post-options',
|
||||
ref: containerRef,
|
||||
onKeyDown: handleKeyDown
|
||||
},
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
className: 'post-options-button',
|
||||
'aria-label': 'Post options',
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': state.open ? 'true' : 'false',
|
||||
title: 'Post options',
|
||||
onClick: () => setState((previous) => togglePostOptions(previous))
|
||||
},
|
||||
React.createElement('span', { 'aria-hidden': 'true' }, '•••')
|
||||
),
|
||||
state.open &&
|
||||
React.createElement(
|
||||
'ul',
|
||||
{
|
||||
className: 'post-options-menu',
|
||||
role: 'menu',
|
||||
'aria-label': 'Post options'
|
||||
},
|
||||
items.map((item, index) =>
|
||||
React.createElement(
|
||||
'li',
|
||||
{
|
||||
key: item.id,
|
||||
className: 'post-options-menu-item',
|
||||
role: 'none'
|
||||
},
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: item.href,
|
||||
target: item.target,
|
||||
rel: item.rel,
|
||||
role: 'menuitem',
|
||||
className: 'post-options-link',
|
||||
tabIndex: index === state.focusedIndex ? 0 : -1,
|
||||
autoFocus: index === state.focusedIndex
|
||||
},
|
||||
item.label
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = PostOptionsMenu
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-16T16:54:35.472Z","module_hash":"68c1b9ad46f613f4d84aecc6be2fc0a2e370b9f3c50b15e50f702f6587e93b45","functions":[{"id":"func/PostOptionsMenu","name":"PostOptionsMenu","line":24,"end_line":111,"hash":"df85571487654169765129cbe38d08247311fbd32bd94ad3a395834a202aa69b"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Post options menu behavior: the shared model behind the three-dots menu on
|
||||
every post card.
|
||||
|
||||
The menu's first item is a "See on block explorer" link to the post
|
||||
transaction on bch.loping.net, opened in a new tab. This module owns the link
|
||||
construction and the pure open/close/focus state transitions so the feed card
|
||||
and the profile post card render identical behavior.
|
||||
|
||||
It is written in plain CommonJS with no UI or DOM concerns so it can be used
|
||||
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'
|
||||
|
||||
// Block explorer URL for a post transaction, or '' without a txid.
|
||||
function explorerTxUrl (txid) {
|
||||
if (!txid) return ''
|
||||
return `${BLOCK_EXPLORER_TX_BASE}/${txid}`
|
||||
}
|
||||
|
||||
// The ordered menu items for a post. The block explorer link is first.
|
||||
function postOptionsItems (txid) {
|
||||
return [
|
||||
{
|
||||
id: 'block-explorer',
|
||||
label: BLOCK_EXPLORER_LABEL,
|
||||
href: explorerTxUrl(txid),
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// A fresh, closed menu state.
|
||||
function initialPostOptionsState () {
|
||||
return { open: false, focusedIndex: -1 }
|
||||
}
|
||||
|
||||
// Open the menu, leaving any current item focus intact.
|
||||
function openPostOptions (state) {
|
||||
return { ...state, open: true }
|
||||
}
|
||||
|
||||
// Close the menu and clear item focus.
|
||||
function closePostOptions (state) {
|
||||
return { ...state, open: false, focusedIndex: -1 }
|
||||
}
|
||||
|
||||
// Open a closed menu, close an open one.
|
||||
function togglePostOptions (state) {
|
||||
return state.open ? closePostOptions(state) : openPostOptions(state)
|
||||
}
|
||||
|
||||
// Move focus to the first menu item, if there is one.
|
||||
function focusFirstPostOption (state, items = []) {
|
||||
if (!items.length) return state
|
||||
return { ...state, open: true, focusedIndex: 0 }
|
||||
}
|
||||
|
||||
// Escape closes the menu.
|
||||
function handlePostOptionsEscape (state) {
|
||||
return closePostOptions(state)
|
||||
}
|
||||
|
||||
// A click outside the menu closes it.
|
||||
function handlePostOptionsOutsideClick (state) {
|
||||
return closePostOptions(state)
|
||||
}
|
||||
|
||||
// True when a mousedown target is outside the menu container. A missing
|
||||
// container (menu not mounted) or target is treated as inside so a detached
|
||||
// menu cannot close itself. Pure so the component's effect stays a thin
|
||||
// adapter and the decision is unit testable without a DOM.
|
||||
function isOutsidePostOptions (container, target) {
|
||||
return Boolean(container) && !container.contains(target)
|
||||
}
|
||||
|
||||
// Resolve a key press to the menu's next state. Returns null for keys that are
|
||||
// not menu shortcuts. ArrowDown reveals the menu and focuses its first item;
|
||||
// Escape closes it. preventDefault is true for keys that would otherwise scroll
|
||||
// the page.
|
||||
function postOptionsKeyCommand (key, items = []) {
|
||||
if (key === 'Escape') {
|
||||
return { transition: handlePostOptionsEscape, preventDefault: false }
|
||||
}
|
||||
|
||||
if (key === 'ArrowDown') {
|
||||
return {
|
||||
transition: (state) => focusFirstPostOption(state, items),
|
||||
preventDefault: true
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_EXPLORER_LABEL,
|
||||
BLOCK_EXPLORER_TX_BASE,
|
||||
explorerTxUrl,
|
||||
postOptionsItems,
|
||||
initialPostOptionsState,
|
||||
openPostOptions,
|
||||
closePostOptions,
|
||||
togglePostOptions,
|
||||
focusFirstPostOption,
|
||||
handlePostOptionsEscape,
|
||||
handlePostOptionsOutsideClick,
|
||||
isOutsidePostOptions,
|
||||
postOptionsKeyCommand
|
||||
}
|
||||
|
||||
// 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"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Property tests for the post options menu.
|
||||
|
||||
The unit tests probe post-options at a few fixed fixtures. These properties
|
||||
pin down the service's and the component's invariants over broad random
|
||||
inputs:
|
||||
|
||||
- explorerTxUrl composes the block explorer base with the txid and returns
|
||||
'' for every falsy input.
|
||||
- postOptionsItems always offers the block explorer link first, with the
|
||||
expected label, target, and rel, and an href equal to explorerTxUrl.
|
||||
- Close transitions are idempotent and always clear focus; open preserves
|
||||
focus; toggle is an involution on the open flag.
|
||||
- focusFirstPostOption (ArrowDown) opens and focuses index 0 when items
|
||||
exist, and is a no-op with no items.
|
||||
- The key command maps only Escape and ArrowDown, never prevents the
|
||||
default for anything else.
|
||||
- The rendered component always shows the button, shows menu items only
|
||||
when open, makes exactly the focused item tabbable, and is deterministic.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const PostOptions = require('../../src/services/post-options')
|
||||
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
|
||||
|
||||
const rng = seededRandom(20260916)
|
||||
|
||||
const HEX = '0123456789abcdef'
|
||||
const OTHER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowLeft', 'Escape ', 'escape', '']
|
||||
|
||||
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 randomState () {
|
||||
return { open: rng() < 0.5, focusedIndex: intGen(rng, -1, 3)() }
|
||||
}
|
||||
|
||||
function render (props) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(PostOptionsMenu, props)
|
||||
)
|
||||
}
|
||||
|
||||
test('explorerTxUrl composes the base and the txid', async () => {
|
||||
await forAll(
|
||||
() => randomTxid(),
|
||||
async (txid) =>
|
||||
PostOptions.explorerTxUrl(txid) === `${PostOptions.BLOCK_EXPLORER_TX_BASE}/${txid}`,
|
||||
{ label: 'explorerTxUrl composition', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('explorerTxUrl returns an empty string for every falsy input', async () => {
|
||||
await forAll(
|
||||
() => [undefined, null, '', 0, false][intGen(rng, 0, 4)()],
|
||||
async (value) => PostOptions.explorerTxUrl(value) === '',
|
||||
{ label: 'explorerTxUrl falsy inputs', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('postOptionsItems always puts the block explorer link first', async () => {
|
||||
await forAll(
|
||||
() => randomTxid(),
|
||||
async (txid) => {
|
||||
const items = PostOptions.postOptionsItems(txid)
|
||||
if (items.length === 0) return false
|
||||
const first = items[0]
|
||||
return first.id === 'block-explorer' &&
|
||||
first.label === PostOptions.BLOCK_EXPLORER_LABEL &&
|
||||
first.target === '_blank' &&
|
||||
first.rel === 'noopener noreferrer' &&
|
||||
first.href === PostOptions.explorerTxUrl(txid)
|
||||
},
|
||||
{ label: 'postOptionsItems block explorer first', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('close transitions are idempotent and clear focus', async () => {
|
||||
await forAll(
|
||||
() => randomState(),
|
||||
async (state) => {
|
||||
const transitions = [
|
||||
PostOptions.closePostOptions,
|
||||
PostOptions.handlePostOptionsEscape,
|
||||
PostOptions.handlePostOptionsOutsideClick
|
||||
]
|
||||
for (const transition of transitions) {
|
||||
const once = transition(state)
|
||||
const twice = transition(once)
|
||||
if (once.open !== false || once.focusedIndex !== -1) return false
|
||||
if (JSON.stringify(once) !== JSON.stringify(twice)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ label: 'post options close idempotence', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('open preserves focus and toggle is an involution on open', async () => {
|
||||
await forAll(
|
||||
() => randomState(),
|
||||
async (state) => {
|
||||
const opened = PostOptions.openPostOptions(state)
|
||||
if (opened.open !== true) return false
|
||||
if (opened.focusedIndex !== state.focusedIndex) return false
|
||||
const twice = PostOptions.togglePostOptions(PostOptions.togglePostOptions(state))
|
||||
return twice.open === state.open
|
||||
},
|
||||
{ label: 'post options open and toggle', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('focusFirstPostOption opens and focuses index 0 only when items exist', async () => {
|
||||
await forAll(
|
||||
() => randomState(),
|
||||
async (state) => {
|
||||
const items = PostOptions.postOptionsItems(randomTxid())
|
||||
const focused = PostOptions.focusFirstPostOption(state, items)
|
||||
if (focused.open !== true || focused.focusedIndex !== 0) return false
|
||||
const empty = PostOptions.focusFirstPostOption(state, [])
|
||||
return JSON.stringify(empty) === JSON.stringify(state)
|
||||
},
|
||||
{ label: 'post options focus first', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('the key command maps only Escape and ArrowDown', async () => {
|
||||
await forAll(
|
||||
() => ({ key: OTHER_KEYS[intGen(rng, 0, OTHER_KEYS.length - 1)()], txid: randomTxid() }),
|
||||
async ({ key, txid }) => {
|
||||
const items = PostOptions.postOptionsItems(txid)
|
||||
if (PostOptions.postOptionsKeyCommand(key, items) !== null) return false
|
||||
|
||||
const escape = PostOptions.postOptionsKeyCommand('Escape', items)
|
||||
if (!escape || escape.preventDefault !== false) return false
|
||||
if (escape.transition(PostOptions.initialPostOptionsState()).open !== false) return false
|
||||
|
||||
const arrowDown = PostOptions.postOptionsKeyCommand('ArrowDown', items)
|
||||
if (!arrowDown || arrowDown.preventDefault !== true) return false
|
||||
const next = arrowDown.transition(PostOptions.initialPostOptionsState())
|
||||
return next.open === true && next.focusedIndex === 0
|
||||
},
|
||||
{ label: 'post options key command', samples: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('the rendered menu shows the button always and items only when open', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(), open: rng() < 0.5 }),
|
||||
async ({ txid, open }) => {
|
||||
const html = render({ txid, initialOpen: open })
|
||||
if (!html.includes('aria-label="Post options"')) return false
|
||||
if (!html.includes(`aria-expanded="${open ? 'true' : 'false'}"`)) return false
|
||||
if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL) !== open) return false
|
||||
if (open && !html.includes(`href="${PostOptions.explorerTxUrl(txid)}"`)) return false
|
||||
if (open && !html.includes('target="_blank"')) return false
|
||||
return true
|
||||
},
|
||||
{ label: 'post options render open and closed', samples: 1000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('exactly the focused menu item is tabbable', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(), focusedIndex: intGen(rng, -1, 0)() }),
|
||||
async ({ txid, focusedIndex }) => {
|
||||
const html = render({ txid, initialOpen: true, initialFocusedIndex: focusedIndex })
|
||||
const tabbable = (html.match(/tabindex="0"/g) || []).length
|
||||
return tabbable === (focusedIndex === 0 ? 1 : 0)
|
||||
},
|
||||
{ label: 'post options tabbable item', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('rendering the same props twice yields the same markup', async () => {
|
||||
await forAll(
|
||||
() => ({
|
||||
txid: randomTxid(),
|
||||
initialOpen: rng() < 0.5,
|
||||
initialFocusedIndex: intGen(rng, -1, 1)()
|
||||
}),
|
||||
async (props) => {
|
||||
const first = render(props)
|
||||
const second = render(props)
|
||||
return first === second
|
||||
},
|
||||
{ label: 'post options render determinism', samples: 500 }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Unit tests for the post options menu.
|
||||
|
||||
Every post card shows a three-dots "Post options" button whose menu's first
|
||||
item is "See on block explorer", a link to the post transaction on
|
||||
bch.loping.net that opens in a new tab. The menu opens on the button, closes
|
||||
on the button again, an outside click, or Escape, and ArrowDown moves focus
|
||||
to the first item.
|
||||
|
||||
The pure service owns the link construction and the open/close/focus state
|
||||
transitions; the component renders that state to markup.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const PostOptions = require('../../src/services/post-options')
|
||||
const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu')
|
||||
|
||||
const TXID = 'c96a46c8b55657fe125115e3ddf5ad30ad587bb41baa952cb8d9be9334161875'
|
||||
const EXPLORER_URL = `https://bch.loping.net/tx/${TXID}`
|
||||
|
||||
function renderMenu (props = {}) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(PostOptionsMenu, { txid: TXID, ...props })
|
||||
)
|
||||
}
|
||||
|
||||
test('explorerTxUrl builds the bch.loping.net transaction link', () => {
|
||||
assert.equal(PostOptions.explorerTxUrl(TXID), EXPLORER_URL)
|
||||
})
|
||||
|
||||
test('explorerTxUrl returns an empty string without a txid', () => {
|
||||
assert.equal(PostOptions.explorerTxUrl(''), '')
|
||||
assert.equal(PostOptions.explorerTxUrl(null), '')
|
||||
assert.equal(PostOptions.explorerTxUrl(undefined), '')
|
||||
})
|
||||
|
||||
test('postOptionsItems puts the block explorer link first', () => {
|
||||
const items = PostOptions.postOptionsItems(TXID)
|
||||
|
||||
assert.equal(items[0].label, 'See on block explorer')
|
||||
assert.equal(items[0].href, EXPLORER_URL)
|
||||
assert.equal(items[0].target, '_blank')
|
||||
assert.equal(items[0].rel, 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('the menu starts hidden', () => {
|
||||
const state = PostOptions.initialPostOptionsState()
|
||||
|
||||
assert.equal(state.open, false)
|
||||
assert.equal(state.focusedIndex, -1)
|
||||
})
|
||||
|
||||
test('toggling the menu opens it, then closes it again', () => {
|
||||
let state = PostOptions.initialPostOptionsState()
|
||||
|
||||
state = PostOptions.togglePostOptions(state)
|
||||
assert.equal(state.open, true)
|
||||
|
||||
state = PostOptions.togglePostOptions(state)
|
||||
assert.equal(state.open, false)
|
||||
})
|
||||
|
||||
test('closing the menu also clears item focus', () => {
|
||||
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
|
||||
const focused = PostOptions.focusFirstPostOption(
|
||||
open,
|
||||
PostOptions.postOptionsItems(TXID)
|
||||
)
|
||||
const closed = PostOptions.closePostOptions(focused)
|
||||
|
||||
assert.equal(closed.open, false)
|
||||
assert.equal(closed.focusedIndex, -1)
|
||||
})
|
||||
|
||||
test('Escape and an outside click both close the menu', () => {
|
||||
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
|
||||
|
||||
assert.equal(PostOptions.handlePostOptionsEscape(open).open, false)
|
||||
assert.equal(PostOptions.handlePostOptionsOutsideClick(open).open, false)
|
||||
})
|
||||
|
||||
test('isOutsidePostOptions is true only for a click outside a mounted menu', () => {
|
||||
const inside = { contains: () => true }
|
||||
const outside = { contains: () => false }
|
||||
|
||||
assert.equal(PostOptions.isOutsidePostOptions(inside, {}), false)
|
||||
assert.equal(PostOptions.isOutsidePostOptions(outside, {}), true)
|
||||
assert.equal(PostOptions.isOutsidePostOptions(null, {}), false)
|
||||
assert.equal(PostOptions.isOutsidePostOptions(undefined, {}), false)
|
||||
})
|
||||
|
||||
test('focusing the first item selects index 0 when items exist', () => {
|
||||
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
|
||||
const focused = PostOptions.focusFirstPostOption(
|
||||
open,
|
||||
PostOptions.postOptionsItems(TXID)
|
||||
)
|
||||
|
||||
assert.equal(focused.focusedIndex, 0)
|
||||
})
|
||||
|
||||
test('focusing the first item is a no-op with no items', () => {
|
||||
const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
|
||||
const focused = PostOptions.focusFirstPostOption(open, [])
|
||||
|
||||
assert.equal(focused.focusedIndex, -1)
|
||||
})
|
||||
|
||||
test('the Escape key command closes the menu without preventing default', () => {
|
||||
const command = PostOptions.postOptionsKeyCommand(
|
||||
'Escape',
|
||||
PostOptions.postOptionsItems(TXID)
|
||||
)
|
||||
|
||||
assert.equal(command.preventDefault, false)
|
||||
const next = command.transition(
|
||||
PostOptions.openPostOptions(PostOptions.initialPostOptionsState())
|
||||
)
|
||||
assert.equal(next.open, false)
|
||||
assert.equal(next.focusedIndex, -1)
|
||||
})
|
||||
|
||||
test('the ArrowDown key command focuses the first item and prevents default', () => {
|
||||
const command = PostOptions.postOptionsKeyCommand(
|
||||
'ArrowDown',
|
||||
PostOptions.postOptionsItems(TXID)
|
||||
)
|
||||
|
||||
assert.equal(command.preventDefault, true)
|
||||
const next = command.transition(PostOptions.initialPostOptionsState())
|
||||
assert.equal(next.open, true)
|
||||
assert.equal(next.focusedIndex, 0)
|
||||
})
|
||||
|
||||
test('the ArrowDown key command keeps the menu closed when there are no items', () => {
|
||||
const command = PostOptions.postOptionsKeyCommand('ArrowDown', [])
|
||||
const next = command.transition(PostOptions.initialPostOptionsState())
|
||||
|
||||
assert.equal(next.open, false)
|
||||
assert.equal(next.focusedIndex, -1)
|
||||
})
|
||||
|
||||
test('other keys have no menu command', () => {
|
||||
assert.equal(PostOptions.postOptionsKeyCommand('Enter', PostOptions.postOptionsItems(TXID)), null)
|
||||
assert.equal(PostOptions.postOptionsKeyCommand('Tab', []), null)
|
||||
})
|
||||
|
||||
test('the closed menu renders the post options button but no items', () => {
|
||||
const html = renderMenu()
|
||||
|
||||
assert.match(html, /aria-label="Post options"/)
|
||||
assert.match(html, /aria-haspopup="menu"/)
|
||||
assert.match(html, /aria-expanded="false"/)
|
||||
assert.doesNotMatch(html, /See on block explorer/)
|
||||
})
|
||||
|
||||
test('the open menu renders the block explorer link that opens in a new tab', () => {
|
||||
const html = renderMenu({ initialOpen: true })
|
||||
|
||||
assert.match(html, /aria-expanded="true"/)
|
||||
assert.match(html, /See on block explorer/)
|
||||
assert.match(html, new RegExp(`href="${EXPLORER_URL.replace(/[.]/g, '\\.')}"`))
|
||||
assert.match(html, /target="_blank"/)
|
||||
assert.match(html, /rel="noopener noreferrer"/)
|
||||
})
|
||||
|
||||
test('the focused first item is tabbable and the others are not', () => {
|
||||
const html = renderMenu({ initialOpen: true, initialFocusedIndex: 0 })
|
||||
|
||||
assert.match(html, /tabindex="0"/)
|
||||
})
|
||||
|
||||
test('an open menu with no focused index makes no item tabbable', () => {
|
||||
const html = renderMenu({ initialOpen: true })
|
||||
|
||||
assert.doesNotMatch(html, /tabindex="0"/)
|
||||
})
|
||||
Reference in New Issue
Block a user