oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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
27import { test } from 'node:test'
28import assert from 'node:assert/strict'
29
30import { mountMenu } from './menu.mjs'
31
32// A minimal element. Only what menu.mjs actually reads.
33function 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.
57function 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.
73globalThis.document = { activeElement: null }
74
75function fakeView() {
76 return { state: {}, dispatch() {}, focus() {} }
77}
78
79test('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
93test('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
100test('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
116test('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
135test('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
153test('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
160test('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