// fixtures/menu.test.mjs — the menu bar's enabling logic. // // THIS FILE EXISTS BECAUSE ITS ABSENCE SHIPPED A BUG. `sync` read the // LOCAL_COMMANDS module constant where it should have read the merged // `localCommands` table, so every command passed in through // extraCommands — paged, infinite, word count, the shortcuts sheet — // was disabled on every editor update and could never be clicked. Three // of a night's deliverables were unreachable from the UI, and nothing // caught it: the Go tests check the model and the markup, the gate // cannot open a browser, and menu.mjs had no test of its own. // // So the point here is narrow and deliberate: exercise `sync` and `run` // against a fake DOM, with no jsdom and no browser. The fake is about // forty lines and covers exactly the surface these two functions touch // — querySelectorAll, a few attributes, and `disabled`. It is not a DOM // implementation and must not grow into one; the moment a test here // needs layout or focus, it belongs in the browser drive instead. // // WHAT IS NOT HERE: whether sync writes those attributes only when they // change. This fake records the state an element ends up in, not the // calls made to get it there, and the difference is a real defect — // menu.mjs used to set `disabled` unconditionally on every editor // update, which this file cannot see because nothing about the outcome // is wrong. fixtures/toolbar.test.mjs holds that property, for this bar // as well as the toolbar's, with a stand-in that records the calls. import { test } from 'node:test' import assert from 'node:assert/strict' import { mountMenu } from './menu.mjs' // A minimal element. Only what menu.mjs actually reads. function el(attrs = {}) { const node = { attrs: { ...attrs }, disabled: true, tabIndex: -1, children: [], getAttribute: (k) => (k in node.attrs ? node.attrs[k] : null), setAttribute: (k, v) => { node.attrs[k] = String(v) }, hasAttribute: (k) => k in node.attrs, focus() { node.focused = true }, closest: () => null, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, } return node } // A root holding a set of command items, answering the two selectors // menu.mjs uses. function rootWith(items) { const titles = [el({ 'estilo-menubar-title': '' })] return { addEventListener() {}, contains: () => false, querySelectorAll(sel) { if (sel === '[estilo-menubar-title]') return titles if (sel === '[estilo-menu-item][data-docs-command]') return items return [] }, querySelector: () => null, } } // document is touched by focusTitle. It does not matter here; this // keeps it from throwing. globalThis.document = { activeElement: null } function fakeView() { return { state: {}, dispatch() {}, focus() {} } } test('an extraCommands item is enabled by sync', () => { // The regression. word_count exists only in extraCommands; before the // fix, sync looked it up in the toolbar's table, found nothing, and // set disabled = true forever. const item = el({ 'data-docs-command': 'word_count' }) const menu = mountMenu(rootWith([item]), { getView: fakeView, commands: {}, extraCommands: { word_count: () => true }, }) menu.sync(fakeView()) assert.equal(item.disabled, false, 'an extraCommands item was left disabled') }) test('print, the module’s own local command, is enabled too', () => { const item = el({ 'data-docs-command': 'print' }) const menu = mountMenu(rootWith([item]), { getView: fakeView, commands: {} }) menu.sync(fakeView()) assert.equal(item.disabled, false) }) test('an editor command follows what the command reports', () => { const yes = el({ 'data-docs-command': 'strong' }) const no = el({ 'data-docs-command': 'undo' }) const menu = mountMenu(rootWith([yes, no]), { getView: fakeView, commands: { // The null-dispatch contract: called to ask, it only reports. strong: () => true, undo: () => false, }, }) menu.sync(fakeView()) assert.equal(yes.disabled, false) assert.equal(no.disabled, true, 'a command that reports false was left enabled') }) test('a checkable item is pressed and enabled while it is the current one', () => { // "Body text" with the caret in body text: setting the style it // already has does nothing, so the command reports false — and the // item used to be drawn DISABLED, with "unavailable" standing in for // "current". It is checked and stays enabled. const body = el({ 'data-docs-command': 'block_paragraph', 'aria-pressed': 'false' }) const h1 = el({ 'data-docs-command': 'block_heading-1', 'aria-pressed': 'true' }) const menu = mountMenu(rootWith([body, h1]), { getView: fakeView, commands: { block_paragraph: () => false, 'block_heading-1': () => true }, isPressed: (name) => name === 'block_paragraph', }) menu.sync(fakeView()) assert.equal(body.getAttribute('aria-pressed'), 'true', 'the current style is not checked') assert.equal(body.disabled, false, 'the current style is drawn as unavailable') assert.equal(h1.getAttribute('aria-pressed'), 'false', 'a style the caret is not in is still checked') assert.equal(h1.disabled, false) }) test('the view pair follows the mode after a switch, with no reload', () => { // View → Paged/Infinite used to draw no state at all, and menu.go's // comment claimed menu.mjs kept aria-pressed in step. It did not. let mode = 'infinite' const paged = el({ 'data-docs-command': 'view_paged', 'aria-pressed': 'false' }) const infinite = el({ 'data-docs-command': 'view_infinite', 'aria-pressed': 'true' }) const menu = mountMenu(rootWith([paged, infinite]), { getView: fakeView, commands: {}, extraCommands: { view_paged: () => true, view_infinite: () => true }, isPressed: (name) => name === 'view_' + mode, }) mode = 'paged' menu.sync(fakeView()) assert.equal(paged.getAttribute('aria-pressed'), 'true') assert.equal(infinite.getAttribute('aria-pressed'), 'false') }) test('an item with no aria-pressed never gains one', () => { const bold = el({ 'data-docs-command': 'strong' }) const menu = mountMenu(rootWith([bold]), { getView: fakeView, commands: { strong: () => true }, isPressed: () => true }) menu.sync(fakeView()) assert.equal(bold.hasAttribute('aria-pressed'), false) }) test('with no view, editor commands are disabled and local ones are not', () => { // Print does not act on a selection, so it is available whenever the // page is; Bold is not. const local = el({ 'data-docs-command': 'print' }) const editor = el({ 'data-docs-command': 'strong' }) const menu = mountMenu(rootWith([local, editor]), { getView: () => null, commands: { strong: () => true }, }) menu.sync(null) assert.equal(local.disabled, false) assert.equal(editor.disabled, true) })