Refactor post options menu and add property tests

Move the keyboard shortcut mapping into the pure post-options service so the
React component is a thin adapter and the Escape/ArrowDown decisions are unit
and property testable. DRY the duplicated post-options acceptance steps behind
two helpers, and add property tests for the explorer URL, menu-item shape,
state transitions, key command, and rendered markup.

The createElement post-options component and service stay scannable by
mutate4javascript; the JSX call sites (post-feed-item, profile) are unchanged
except for rendering the shared menu.

By refactorer.
This commit is contained in:
Chris Troutner
2026-09-16 09:48:36 -07:00
parent 20e67da216
commit d0882bbd82
5 changed files with 282 additions and 27 deletions
+18 -15
View File
@@ -3298,20 +3298,14 @@ const handlers = [
name: 'click post options button',
pattern: /^I click the post options button for the post with txid (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
Object.assign(menu, PostOptions.togglePostOptions(menu))
world.activeMenuTxid = txid
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) {
const txid = resolveParam(m[1], example)
const menu = getPostOptionsMenu(world, txid)
Object.assign(menu, PostOptions.togglePostOptions(menu))
world.activeMenuTxid = txid
togglePostOptionsMenu(world, resolveParam(m[1], example))
}
},
{
@@ -3362,16 +3356,14 @@ const handlers = [
name: 'click outside post options menu',
pattern: /^I click outside the post options menu$/,
run (m, example, world) {
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
Object.assign(menu, PostOptions.handlePostOptionsOutsideClick(menu))
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick)
}
},
{
name: 'press Escape key',
pattern: /^I press the Escape key$/,
run (m, example, world) {
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
Object.assign(menu, PostOptions.handlePostOptionsEscape(menu))
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape)
}
},
{
@@ -3379,9 +3371,7 @@ const handlers = [
pattern: /^I press the ArrowDown key$/,
run (m, example, world) {
const txid = world.activeMenuTxid
const menu = getPostOptionsMenu(world, txid)
Object.assign(
menu,
transitionActivePostOptionsMenu(world, (menu) =>
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
)
}
@@ -3412,6 +3402,19 @@ function getPostOptionsMenu (world, txid) {
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 || ''
@@ -16,9 +16,8 @@ const React = require('react')
const {
postOptionsItems,
togglePostOptions,
focusFirstPostOption,
handlePostOptionsEscape,
handlePostOptionsOutsideClick
handlePostOptionsOutsideClick,
postOptionsKeyCommand
} = require('../../services/post-options')
function PostOptionsMenu ({
@@ -48,15 +47,11 @@ function PostOptionsMenu ({
}, [state.open])
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setState((previous) => handlePostOptionsEscape(previous))
return
}
const command = postOptionsKeyCommand(event.key, items)
if (!command) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setState((previous) => focusFirstPostOption(previous, items))
}
if (command.preventDefault) event.preventDefault()
setState((previous) => command.transition(previous))
}
return React.createElement(
+21 -1
View File
@@ -69,6 +69,25 @@ function handlePostOptionsOutsideClick (state) {
return closePostOptions(state)
}
// 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,
@@ -80,5 +99,6 @@ module.exports = {
togglePostOptions,
focusFirstPostOption,
handlePostOptionsEscape,
handlePostOptionsOutsideClick
handlePostOptionsOutsideClick,
postOptionsKeyCommand
}
@@ -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 }
)
})
@@ -101,6 +101,45 @@ test('focusing the first item is a no-op with no items', () => {
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()