diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 03340c3..c128a24 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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 || '' diff --git a/psf-memo-client/src/components/post-feed/post-options-menu.js b/psf-memo-client/src/components/post-feed/post-options-menu.js index 5610a66..af4a272 100644 --- a/psf-memo-client/src/components/post-feed/post-options-menu.js +++ b/psf-memo-client/src/components/post-feed/post-options-menu.js @@ -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( diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js index a992be7..74ea937 100644 --- a/psf-memo-client/src/services/post-options.js +++ b/psf-memo-client/src/services/post-options.js @@ -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 } diff --git a/psf-memo-client/test/property/post-options.property.test.js b/psf-memo-client/test/property/post-options.property.test.js new file mode 100644 index 0000000..0cced88 --- /dev/null +++ b/psf-memo-client/test/property/post-options.property.test.js @@ -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 } + ) +}) diff --git a/psf-memo-client/test/unit/post-options.test.js b/psf-memo-client/test/unit/post-options.test.js index db93202..72c91e2 100644 --- a/psf-memo-client/test/unit/post-options.test.js +++ b/psf-memo-client/test/unit/post-options.test.js @@ -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()