forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontextMenuTemplate.test.ts
More file actions
258 lines (232 loc) · 9.04 KB
/
Copy pathcontextMenuTemplate.test.ts
File metadata and controls
258 lines (232 loc) · 9.04 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import { describe, it, expect, vi } from 'vitest'
import { buildContextMenuTemplate } from './contextMenuTemplate'
import type { ContextMenuDeps, ContextMenuInput } from './contextMenuTemplate'
type Flags = ContextMenuInput['editFlags']
const flags = (over: Partial<Flags> = {}): Flags => ({
canUndo: false,
canRedo: false,
canCut: false,
canCopy: false,
canPaste: false,
canDelete: false,
canSelectAll: false,
canEditRichly: false,
...over
})
// Defaults to "no misspelling" and "not on a link" (linkURL is '' off a link in
// real Electron); spell-check and link cases opt in.
const input = (over: Partial<ContextMenuInput> = {}): ContextMenuInput => ({
isEditable: false,
selectionText: '',
editFlags: flags(),
misspelledWord: '',
dictionarySuggestions: [],
linkURL: '',
...over
})
const build = (over: Partial<ContextMenuInput> = {}): ReturnType<typeof buildContextMenuTemplate> =>
buildContextMenuTemplate(input(over))
// A menu item is identified by its role, its separator-ness, or its label (the
// spelling suggestions are labels, not roles).
const ids = (t: ReturnType<typeof buildContextMenuTemplate>): (string | undefined)[] =>
t.map((i) => i.role ?? i.type ?? i.label)
const ALL_EDIT = flags({
canUndo: true,
canRedo: true,
canCut: true,
canCopy: true,
canPaste: true,
canDelete: true,
canSelectAll: true
})
describe('buildContextMenuTemplate', () => {
it('offers nothing on a read-only area with no selection (caller must not popup)', () => {
expect(build()).toEqual([])
})
it('treats a whitespace-only selection as no selection', () => {
expect(build({ selectionText: ' \n ', editFlags: flags({ canCopy: true }) })).toEqual([])
})
it('offers copy (+ select all) on selected read-only text', () => {
const t = build({
selectionText: 'hello',
editFlags: flags({ canCopy: true, canSelectAll: true })
})
expect(ids(t)).toEqual(['copy', 'separator', 'selectAll'])
})
it('offers nothing on read-only text that cannot be copied', () => {
// The file's own rule is "filter on editFlags", and the read-only branch used
// to ignore canCopy — the one place the policy was broken.
expect(build({ selectionText: 'hello', editFlags: flags({ canCopy: false }) })).toEqual([])
})
it('offers the full edit set in an editable field, Delete included', () => {
const t = build({ isEditable: true, selectionText: 'sel', editFlags: ALL_EDIT })
expect(ids(t)).toEqual([
'undo',
'redo',
'separator',
'cut',
'copy',
'paste',
'delete',
'separator',
'selectAll'
])
})
it('omits Delete when there is nothing selected to delete', () => {
const t = build({ isEditable: true, selectionText: '', editFlags: ALL_EDIT })
expect(ids(t)).not.toContain('delete')
})
it('hides inapplicable commands rather than showing them disabled', () => {
// Empty composer, clipboard has content: paste + select all only.
const t = build({
isEditable: true,
editFlags: flags({ canPaste: true, canSelectAll: true })
})
expect(ids(t)).toEqual(['paste', 'separator', 'selectAll'])
})
it('offers spelling suggestions FIRST on a misspelled word, plus Add to dictionary', () => {
// Electron enables spellcheck by default, so the composer already draws red
// squiggles. A menu with no corrections would show a misspelling it refuses
// to fix.
const t = build({
isEditable: true,
misspelledWord: 'teh',
dictionarySuggestions: ['the', 'ten'],
editFlags: flags({ canPaste: true, canSelectAll: true })
})
expect(ids(t)).toEqual([
'the',
'ten',
'separator',
'Add to dictionary',
'separator',
'paste',
'separator',
'selectAll'
])
})
it('says so explicitly when a misspelled word has no suggestions', () => {
const t = build({ isEditable: true, misspelledWord: 'zzxq', dictionarySuggestions: [] })
expect(ids(t)).toEqual(['No spelling suggestions', 'separator', 'Add to dictionary'])
expect(t[0].enabled).toBe(false)
})
it('never starts or ends with a separator, and never emits adjacent separators', () => {
// Exhaustive sweep: a stray divider is THE classic bug in conditionally
// assembled menus. Varies isEditable, all six flags the builder reads, and
// whether a misspelling with/without suggestions is present — 2 * 2^6 * 3 = 384
// combinations. The spelling group made this materially more likely to break:
// it is emitted BEFORE the rest and can be the only group present.
const bools = [false, true]
const spellings = [
{ misspelledWord: '', dictionarySuggestions: [] as string[] },
{ misspelledWord: 'teh', dictionarySuggestions: ['the'] },
{ misspelledWord: 'zzxq', dictionarySuggestions: [] as string[] }
]
let checked = 0
for (const isEditable of bools)
for (const canUndo of bools)
for (const canRedo of bools)
for (const canCut of bools)
for (const canCopy of bools)
for (const canPaste of bools)
for (const canSelectAll of bools)
for (const spelling of spellings) {
const t = build({
isEditable,
selectionText: 'sel',
editFlags: flags({
canUndo,
canRedo,
canCut,
canCopy,
canPaste,
canSelectAll,
canDelete: true
}),
...spelling
})
checked++
if (!t.length) continue
const r = ids(t)
expect(r[0]).not.toBe('separator')
expect(r[r.length - 1]).not.toBe('separator')
for (let i = 1; i < r.length; i++) {
expect(r[i] === 'separator' && r[i - 1] === 'separator').toBe(false)
}
}
expect(checked).toBe(384)
})
// --- Link group (right-click on a hyperlink) -----------------------------
const fakeDeps = (): ContextMenuDeps & {
openExternal: ReturnType<typeof vi.fn>
copyText: ReturnType<typeof vi.fn>
} => ({ openExternal: vi.fn(), copyText: vi.fn() })
const clickItem = (item: ReturnType<typeof buildContextMenuTemplate>[number]): void => {
;(item.click as unknown as () => void)()
}
it('prepends Open Link + Copy Link Address on an http/https link', () => {
const t = buildContextMenuTemplate(input({ linkURL: 'https://omi.me/x' }), fakeDeps())
expect(ids(t)).toEqual(['Open Link', 'Copy Link Address'])
})
it('clicking the link items invokes the injected deps with the exact URL', () => {
const deps = fakeDeps()
const url = 'https://omi.me/docs?token=abc'
const t = buildContextMenuTemplate(input({ linkURL: url }), deps)
const open = t.find((i) => i.label === 'Open Link')!
const copy = t.find((i) => i.label === 'Copy Link Address')!
clickItem(open)
expect(deps.openExternal).toHaveBeenCalledWith(url)
clickItem(copy)
expect(deps.copyText).toHaveBeenCalledWith(url)
})
it('puts the link group first, then a separator, then editing items', () => {
// A link inside an editable field (rare, but the ordering must hold): Chrome/
// Edge show link actions ahead of the edit commands.
const t = buildContextMenuTemplate(
input({ isEditable: true, selectionText: 'sel', editFlags: ALL_EDIT, linkURL: 'http://a.b' }),
fakeDeps()
)
expect(ids(t)).toEqual([
'Open Link',
'Copy Link Address',
'separator',
'undo',
'redo',
'separator',
'cut',
'copy',
'paste',
'delete',
'separator',
'selectAll'
])
})
it('omits the link group for a non-web scheme (file/UNC/custom-protocol/js)', () => {
for (const linkURL of [
'file:///etc/passwd',
'\\\\host\\share\\x',
'javascript:alert(1)',
'mailto:a@b.com',
'not a url'
]) {
const deps = fakeDeps()
const t = buildContextMenuTemplate(input({ linkURL }), deps)
expect(ids(t)).not.toContain('Open Link')
expect(deps.openExternal).not.toHaveBeenCalled()
}
})
it('is byte-identical to the editing menu when there is no link', () => {
// Same params, with and without deps → the deps path must not perturb the
// editing menu at all.
const editable = { isEditable: true, selectionText: 'sel', editFlags: ALL_EDIT }
const withDeps = buildContextMenuTemplate(input(editable), fakeDeps())
const plain = buildContextMenuTemplate(input(editable))
expect(ids(withDeps)).toEqual(ids(plain))
})
it('emits no link group without injected deps, even on a valid link', () => {
// The pure builder cannot fabricate the electron-touching actions; a menu
// with a live "Open Link" that does nothing would be worse than none.
const t = buildContextMenuTemplate(input({ linkURL: 'https://omi.me' }))
expect(ids(t)).not.toContain('Open Link')
})
})