mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -3298,20 +3298,14 @@ const handlers = [
|
|||||||
name: 'click post options button',
|
name: 'click post options button',
|
||||||
pattern: /^I click the post options button for the post with txid (.+)$/,
|
pattern: /^I click the post options button for the post with txid (.+)$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const txid = resolveParam(m[1], example)
|
togglePostOptionsMenu(world, resolveParam(m[1], example))
|
||||||
const menu = getPostOptionsMenu(world, txid)
|
|
||||||
Object.assign(menu, PostOptions.togglePostOptions(menu))
|
|
||||||
world.activeMenuTxid = txid
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'click post options button again',
|
name: 'click post options button again',
|
||||||
pattern: /^I click the post options button again for the post with txid (.+)$/,
|
pattern: /^I click the post options button again for the post with txid (.+)$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const txid = resolveParam(m[1], example)
|
togglePostOptionsMenu(world, resolveParam(m[1], example))
|
||||||
const menu = getPostOptionsMenu(world, txid)
|
|
||||||
Object.assign(menu, PostOptions.togglePostOptions(menu))
|
|
||||||
world.activeMenuTxid = txid
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3362,16 +3356,14 @@ const handlers = [
|
|||||||
name: 'click outside post options menu',
|
name: 'click outside post options menu',
|
||||||
pattern: /^I click outside the post options menu$/,
|
pattern: /^I click outside the post options menu$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
|
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick)
|
||||||
Object.assign(menu, PostOptions.handlePostOptionsOutsideClick(menu))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'press Escape key',
|
name: 'press Escape key',
|
||||||
pattern: /^I press the Escape key$/,
|
pattern: /^I press the Escape key$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const menu = getPostOptionsMenu(world, world.activeMenuTxid)
|
transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape)
|
||||||
Object.assign(menu, PostOptions.handlePostOptionsEscape(menu))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -3379,9 +3371,7 @@ const handlers = [
|
|||||||
pattern: /^I press the ArrowDown key$/,
|
pattern: /^I press the ArrowDown key$/,
|
||||||
run (m, example, world) {
|
run (m, example, world) {
|
||||||
const txid = world.activeMenuTxid
|
const txid = world.activeMenuTxid
|
||||||
const menu = getPostOptionsMenu(world, txid)
|
transitionActivePostOptionsMenu(world, (menu) =>
|
||||||
Object.assign(
|
|
||||||
menu,
|
|
||||||
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
|
PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3412,6 +3402,19 @@ function getPostOptionsMenu (world, txid) {
|
|||||||
return world.postOptionsMenus[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.
|
// The posts currently rendered by the page the scenario has opened.
|
||||||
function postsOnCurrentPage (world) {
|
function postsOnCurrentPage (world) {
|
||||||
const path = world.currentPath || ''
|
const path = world.currentPath || ''
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ const React = require('react')
|
|||||||
const {
|
const {
|
||||||
postOptionsItems,
|
postOptionsItems,
|
||||||
togglePostOptions,
|
togglePostOptions,
|
||||||
focusFirstPostOption,
|
handlePostOptionsOutsideClick,
|
||||||
handlePostOptionsEscape,
|
postOptionsKeyCommand
|
||||||
handlePostOptionsOutsideClick
|
|
||||||
} = require('../../services/post-options')
|
} = require('../../services/post-options')
|
||||||
|
|
||||||
function PostOptionsMenu ({
|
function PostOptionsMenu ({
|
||||||
@@ -48,15 +47,11 @@ function PostOptionsMenu ({
|
|||||||
}, [state.open])
|
}, [state.open])
|
||||||
|
|
||||||
const handleKeyDown = (event) => {
|
const handleKeyDown = (event) => {
|
||||||
if (event.key === 'Escape') {
|
const command = postOptionsKeyCommand(event.key, items)
|
||||||
setState((previous) => handlePostOptionsEscape(previous))
|
if (!command) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'ArrowDown') {
|
if (command.preventDefault) event.preventDefault()
|
||||||
event.preventDefault()
|
setState((previous) => command.transition(previous))
|
||||||
setState((previous) => focusFirstPostOption(previous, items))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
|
|||||||
@@ -69,6 +69,25 @@ function handlePostOptionsOutsideClick (state) {
|
|||||||
return closePostOptions(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 = {
|
module.exports = {
|
||||||
BLOCK_EXPLORER_LABEL,
|
BLOCK_EXPLORER_LABEL,
|
||||||
BLOCK_EXPLORER_TX_BASE,
|
BLOCK_EXPLORER_TX_BASE,
|
||||||
@@ -80,5 +99,6 @@ module.exports = {
|
|||||||
togglePostOptions,
|
togglePostOptions,
|
||||||
focusFirstPostOption,
|
focusFirstPostOption,
|
||||||
handlePostOptionsEscape,
|
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)
|
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', () => {
|
test('the closed menu renders the post options button but no items', () => {
|
||||||
const html = renderMenu()
|
const html = renderMenu()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user