mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Harden post-options menu: testable outside-click, kill mutation survivors
Move the outside-click decision into the pure post-options service so the effect stays a thin adapter and the previously uncovered `&&` mutation is unit testable. Add unit tests for the outside-click predicate and for the default no-focus open menu, killing the two surviving `-1` default-focus mutations. Stamp the mutate4javascript manifests for both modules and the soft acceptance-mutation manifest for post-options-menu. By architect.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
postOptionsItems,
|
||||
togglePostOptions,
|
||||
handlePostOptionsOutsideClick,
|
||||
isOutsidePostOptions,
|
||||
postOptionsKeyCommand
|
||||
} = require('../../services/post-options')
|
||||
|
||||
@@ -37,7 +38,7 @@ function PostOptionsMenu ({
|
||||
if (!state.open) return undefined
|
||||
|
||||
const handleDocumentMouseDown = (event) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
||||
if (isOutsidePostOptions(containerRef.current, event.target)) {
|
||||
setState((previous) => handlePostOptionsOutsideClick(previous))
|
||||
}
|
||||
}
|
||||
@@ -110,3 +111,7 @@ function PostOptionsMenu ({
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -69,6 +69,14 @@ 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
|
||||
@@ -100,5 +108,10 @@ module.exports = {
|
||||
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
|
||||
|
||||
@@ -84,6 +84,16 @@ test('Escape and an outside click both close the menu', () => {
|
||||
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(
|
||||
@@ -164,3 +174,9 @@ test('the focused first item is tabbable and the others are not', () => {
|
||||
|
||||
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