| 1 | // fixtures/menu.test.mjs — the menu bar's enabling logic. |
| 2 | // |
| 3 | // THIS FILE EXISTS BECAUSE ITS ABSENCE SHIPPED A BUG. `sync` read the |
| 4 | // LOCAL_COMMANDS module constant where it should have read the merged |
| 5 | // `localCommands` table, so every command passed in through |
| 6 | // extraCommands — paged, infinite, word count, the shortcuts sheet — |
| 7 | // was disabled on every editor update and could never be clicked. Three |
| 8 | // of a night's deliverables were unreachable from the UI, and nothing |
| 9 | // caught it: the Go tests check the model and the markup, the gate |
| 10 | // cannot open a browser, and menu.mjs had no test of its own. |
| 11 | // |
| 12 | // So the point here is narrow and deliberate: exercise `sync` and `run` |
| 13 | // against a fake DOM, with no jsdom and no browser. The fake is about |
| 14 | // forty lines and covers exactly the surface these two functions touch |
| 15 | // — querySelectorAll, a few attributes, and `disabled`. It is not a DOM |
| 16 | // implementation and must not grow into one; the moment a test here |
| 17 | // needs layout or focus, it belongs in the browser drive instead. |
| 18 | // |
| 19 | // WHAT IS NOT HERE: whether sync writes those attributes only when they |
| 20 | // change. This fake records the state an element ends up in, not the |
| 21 | // calls made to get it there, and the difference is a real defect — |
| 22 | // menu.mjs used to set `disabled` unconditionally on every editor |
| 23 | // update, which this file cannot see because nothing about the outcome |
| 24 | // is wrong. fixtures/toolbar.test.mjs holds that property, for this bar |
| 25 | // as well as the toolbar's, with a stand-in that records the calls. |
| 26 | |
| 27 | import { test } from 'node:test' |
| 28 | import assert from 'node:assert/strict' |
| 29 | |
| 30 | import { mountMenu } from './menu.mjs' |
| 31 | |
| 32 | // A minimal element. Only what menu.mjs actually reads. |
| 33 | function el(attrs = {}) { |
| 34 | const node = { |
| 35 | attrs: { ...attrs }, |
| 36 | disabled: true, |
| 37 | tabIndex: -1, |
| 38 | children: [], |
| 39 | getAttribute: (k) => (k in node.attrs ? node.attrs[k] : null), |
| 40 | setAttribute: (k, v) => { |
| 41 | node.attrs[k] = String(v) |
| 42 | }, |
| 43 | hasAttribute: (k) => k in node.attrs, |
| 44 | focus() { |
| 45 | node.focused = true |
| 46 | }, |
| 47 | closest: () => null, |
| 48 | querySelector: () => null, |
| 49 | querySelectorAll: () => [], |
| 50 | addEventListener() {}, |
| 51 | } |
| 52 | return node |
| 53 | } |
| 54 | |
| 55 | // A root holding a set of command items, answering the two selectors |
| 56 | // menu.mjs uses. |
| 57 | function rootWith(items) { |
| 58 | const titles = [el({ 'estilo-menubar-title': '' })] |
| 59 | return { |
| 60 | addEventListener() {}, |
| 61 | contains: () => false, |
| 62 | querySelectorAll(sel) { |
| 63 | if (sel === '[estilo-menubar-title]') return titles |
| 64 | if (sel === '[estilo-menu-item][data-docs-command]') return items |
| 65 | return [] |
| 66 | }, |
| 67 | querySelector: () => null, |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // document is touched by focusTitle. It does not matter here; this |
| 72 | // keeps it from throwing. |
| 73 | globalThis.document = { activeElement: null } |
| 74 | |
| 75 | function fakeView() { |
| 76 | return { state: {}, dispatch() {}, focus() {} } |
| 77 | } |
| 78 | |
| 79 | test('an extraCommands item is enabled by sync', () => { |
| 80 | // The regression. word_count exists only in extraCommands; before the |
| 81 | // fix, sync looked it up in the toolbar's table, found nothing, and |
| 82 | // set disabled = true forever. |
| 83 | const item = el({ 'data-docs-command': 'word_count' }) |
| 84 | const menu = mountMenu(rootWith([item]), { |
| 85 | getView: fakeView, |
| 86 | commands: {}, |
| 87 | extraCommands: { word_count: () => true }, |
| 88 | }) |
| 89 | menu.sync(fakeView()) |
| 90 | assert.equal(item.disabled, false, 'an extraCommands item was left disabled') |
| 91 | }) |
| 92 | |
| 93 | test('print, the module’s own local command, is enabled too', () => { |
| 94 | const item = el({ 'data-docs-command': 'print' }) |
| 95 | const menu = mountMenu(rootWith([item]), { getView: fakeView, commands: {} }) |
| 96 | menu.sync(fakeView()) |
| 97 | assert.equal(item.disabled, false) |
| 98 | }) |
| 99 | |
| 100 | test('an editor command follows what the command reports', () => { |
| 101 | const yes = el({ 'data-docs-command': 'strong' }) |
| 102 | const no = el({ 'data-docs-command': 'undo' }) |
| 103 | const menu = mountMenu(rootWith([yes, no]), { |
| 104 | getView: fakeView, |
| 105 | commands: { |
| 106 | // The null-dispatch contract: called to ask, it only reports. |
| 107 | strong: () => true, |
| 108 | undo: () => false, |
| 109 | }, |
| 110 | }) |
| 111 | menu.sync(fakeView()) |
| 112 | assert.equal(yes.disabled, false) |
| 113 | assert.equal(no.disabled, true, 'a command that reports false was left enabled') |
| 114 | }) |
| 115 | |
| 116 | test('a checkable item is pressed and enabled while it is the current one', () => { |
| 117 | // "Body text" with the caret in body text: setting the style it |
| 118 | // already has does nothing, so the command reports false — and the |
| 119 | // item used to be drawn DISABLED, with "unavailable" standing in for |
| 120 | // "current". It is checked and stays enabled. |
| 121 | const body = el({ 'data-docs-command': 'block_paragraph', 'aria-pressed': 'false' }) |
| 122 | const h1 = el({ 'data-docs-command': 'block_heading-1', 'aria-pressed': 'true' }) |
| 123 | const menu = mountMenu(rootWith([body, h1]), { |
| 124 | getView: fakeView, |
| 125 | commands: { block_paragraph: () => false, 'block_heading-1': () => true }, |
| 126 | isPressed: (name) => name === 'block_paragraph', |
| 127 | }) |
| 128 | menu.sync(fakeView()) |
| 129 | assert.equal(body.getAttribute('aria-pressed'), 'true', 'the current style is not checked') |
| 130 | assert.equal(body.disabled, false, 'the current style is drawn as unavailable') |
| 131 | assert.equal(h1.getAttribute('aria-pressed'), 'false', 'a style the caret is not in is still checked') |
| 132 | assert.equal(h1.disabled, false) |
| 133 | }) |
| 134 | |
| 135 | test('the view pair follows the mode after a switch, with no reload', () => { |
| 136 | // View → Paged/Infinite used to draw no state at all, and menu.go's |
| 137 | // comment claimed menu.mjs kept aria-pressed in step. It did not. |
| 138 | let mode = 'infinite' |
| 139 | const paged = el({ 'data-docs-command': 'view_paged', 'aria-pressed': 'false' }) |
| 140 | const infinite = el({ 'data-docs-command': 'view_infinite', 'aria-pressed': 'true' }) |
| 141 | const menu = mountMenu(rootWith([paged, infinite]), { |
| 142 | getView: fakeView, |
| 143 | commands: {}, |
| 144 | extraCommands: { view_paged: () => true, view_infinite: () => true }, |
| 145 | isPressed: (name) => name === 'view_' + mode, |
| 146 | }) |
| 147 | mode = 'paged' |
| 148 | menu.sync(fakeView()) |
| 149 | assert.equal(paged.getAttribute('aria-pressed'), 'true') |
| 150 | assert.equal(infinite.getAttribute('aria-pressed'), 'false') |
| 151 | }) |
| 152 | |
| 153 | test('an item with no aria-pressed never gains one', () => { |
| 154 | const bold = el({ 'data-docs-command': 'strong' }) |
| 155 | const menu = mountMenu(rootWith([bold]), { getView: fakeView, commands: { strong: () => true }, isPressed: () => true }) |
| 156 | menu.sync(fakeView()) |
| 157 | assert.equal(bold.hasAttribute('aria-pressed'), false) |
| 158 | }) |
| 159 | |
| 160 | test('with no view, editor commands are disabled and local ones are not', () => { |
| 161 | // Print does not act on a selection, so it is available whenever the |
| 162 | // page is; Bold is not. |
| 163 | const local = el({ 'data-docs-command': 'print' }) |
| 164 | const editor = el({ 'data-docs-command': 'strong' }) |
| 165 | const menu = mountMenu(rootWith([local, editor]), { |
| 166 | getView: () => null, |
| 167 | commands: { strong: () => true }, |
| 168 | }) |
| 169 | menu.sync(null) |
| 170 | assert.equal(local.disabled, false) |
| 171 | assert.equal(editor.disabled, true) |
| 172 | }) |
| 173 | |