@morscherlab/mint-sdk 1.4.0-beta.18 → 1.4.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,15 @@
1
- import { mount } from '@vue/test-utils'
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
2
  import { describe, expect, it, vi } from 'vitest'
3
- import { defineComponent, h, nextTick } from 'vue'
3
+ import { defineComponent, h, nextTick, shallowRef } from 'vue'
4
4
 
5
+ import BaseModal from '../../components/BaseModal.vue'
5
6
  import JobsStatusTray from '../../components/JobsStatusTray.vue'
6
- import { useJobCenter, type PluginJobCenterSource, type UseJobCenterReturn } from '../../composables/useJobCenter'
7
+ import {
8
+ useJobCenter,
9
+ type PluginJobCenterSource,
10
+ type UseJobCenterOptions,
11
+ type UseJobCenterReturn,
12
+ } from '../../composables/useJobCenter'
7
13
  import type { JobState, JobStateInput, JobStatus } from '../../types/jobs'
8
14
 
9
15
  function job(status: JobStatus, id: string = status): JobStateInput {
@@ -43,17 +49,24 @@ function job(status: JobStatus, id: string = status): JobStateInput {
43
49
  async function openTray(options: {
44
50
  source: PluginJobCenterSource
45
51
  slots?: Record<string, (props: { job: JobState }) => unknown>
52
+ confirm?: InstanceType<typeof JobsStatusTray>['$props']['confirm']
53
+ rowJob?: UseJobCenterOptions['rowJob']
54
+ /** Run real transitions, so a leaving dialog stays in the DOM for a frame. */
55
+ transitions?: boolean
46
56
  }) {
47
57
  const Host = defineComponent({
48
58
  setup() {
49
- const center = useJobCenter({ source: options.source, loadOnMount: false })
59
+ const center = useJobCenter({ source: options.source, loadOnMount: false, rowJob: options.rowJob })
50
60
  return () => [
51
61
  h('button', { class: 'opener', onClick: center.toggle }, 'Jobs'),
52
- h(JobsStatusTray, { center, teleportTo: false }, options.slots),
62
+ h(JobsStatusTray, { center, teleportTo: false, confirm: options.confirm }, options.slots),
53
63
  ]
54
64
  },
55
65
  })
56
- const wrapper = mount(Host, { attachTo: document.body })
66
+ const wrapper = mount(Host, {
67
+ attachTo: document.body,
68
+ global: options.transitions ? { stubs: { transition: false } } : {},
69
+ })
57
70
  const opener = wrapper.get('.opener')
58
71
  ;(opener.element as HTMLElement).focus()
59
72
  await opener.trigger('click')
@@ -255,4 +268,227 @@ describe('JobsStatusTray', () => {
255
268
  wrapper.unmount()
256
269
  })
257
270
  })
271
+
272
+ it('should draw a row from the center row view when a rowJob option overrides it', async () => {
273
+ const wrapper = await openTray({
274
+ source: { jobs: [{ ...job('awaiting_input', 'A-1'), can_cancel: true }], cancel: vi.fn(async () => undefined) },
275
+ rowJob: row => (row.status === 'awaiting_input' ? { status: 'running', percent: 40, message: 'Decoding in the browser' } : null),
276
+ })
277
+ const row = wrapper.get('.mint-jobs-tray__row')
278
+
279
+ expect({
280
+ section: wrapper.get('[data-section="active"]').text().replace(/\s+/g, ' '),
281
+ status: row.attributes('data-status'),
282
+ tinted: row.classes('mint-jobs-tray__row--awaiting-input'),
283
+ icon: row.get('[role="img"]').attributes('aria-label'),
284
+ value: row.get('.mint-jobs-tray__value').text(),
285
+ progress: row.get('[role="progressbar"]').attributes('aria-valuenow'),
286
+ detail: row.get('.mint-jobs-tray__detail').text(),
287
+ cancel: row.find('[aria-label="Cancel Job A-1"]').exists(),
288
+ }).toEqual({
289
+ section: 'Active · 1',
290
+ status: 'awaiting_input',
291
+ tinted: false,
292
+ icon: 'Running',
293
+ value: '40%',
294
+ progress: '40',
295
+ detail: 'Decoding in the browser',
296
+ cancel: true,
297
+ })
298
+ })
299
+
300
+ it('should mount no confirm dialog when confirm is off', async () => {
301
+ const wrapper = await openTray({ source: { jobs: [job('completed')], delete: vi.fn(async () => true) } })
302
+ expect(wrapper.findComponent(BaseModal).exists()).toBe(false)
303
+ wrapper.unmount()
304
+ })
305
+
306
+ it('should keep the tray open when an inner handler prevents the Escape default', async () => {
307
+ const wrapper = await openTray({
308
+ source: { jobs: [job('running', 'R-1')] },
309
+ slots: {
310
+ 'job-actions': () => h('input', {
311
+ class: 'inner',
312
+ onKeydown: (event: KeyboardEvent) => { if (event.key === 'Escape') event.preventDefault() },
313
+ }),
314
+ },
315
+ })
316
+ const inner = wrapper.get('.inner').element as HTMLElement
317
+ inner.focus()
318
+
319
+ inner.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
320
+ await nextTick()
321
+
322
+ expect(wrapper.find('.mint-jobs-tray__panel').exists()).toBe(true)
323
+ wrapper.unmount()
324
+ })
325
+
326
+ it('should leave focus on the page when a job update arrives while nothing in the tray is focused', async () => {
327
+ const jobs = shallowRef([job('running', 'R-1')])
328
+ const wrapper = await openTray({ source: { jobs } })
329
+ ;(document.activeElement as HTMLElement).blur()
330
+
331
+ jobs.value = [job('completed', 'R-1')]
332
+ await nextTick()
333
+ await nextTick()
334
+
335
+ expect(document.activeElement).toBe(document.body)
336
+ wrapper.unmount()
337
+ })
338
+
339
+ it('should focus the panel when the focused row leaves the list while the tray is open', async () => {
340
+ const jobs = shallowRef([job('completed', 'C-1'), job('completed', 'C-2')])
341
+ const wrapper = await openTray({ source: { jobs, delete: vi.fn(async () => true) } })
342
+ ;(wrapper.get('[aria-label="Delete Job C-1"]').element as HTMLElement).focus()
343
+
344
+ jobs.value = [job('completed', 'C-2')]
345
+ await nextTick()
346
+ await nextTick()
347
+
348
+ expect(document.activeElement).toBe(wrapper.get('.mint-jobs-tray__panel').element)
349
+ wrapper.unmount()
350
+ })
351
+
352
+ describe('confirmation', () => {
353
+ const dialog = () => document.body.querySelector<HTMLElement>('.mint-modal [role="dialog"], .mint-modal__container')
354
+
355
+ function confirmSource() {
356
+ return {
357
+ jobs: [{ ...job('running', 'R-1'), can_cancel: true }, job('completed', 'C-1'), job('failed', 'F-1')],
358
+ cancel: vi.fn(async () => undefined),
359
+ delete: vi.fn(async () => true),
360
+ clear: vi.fn(async () => 2),
361
+ }
362
+ }
363
+ const triggers = {
364
+ cancel: '[aria-label="Cancel Job R-1"]',
365
+ delete: '[aria-label="Delete Job C-1"]',
366
+ clear: '[data-section="finished"] .mint-jobs-tray__section-action',
367
+ } as const
368
+
369
+ it.each([
370
+ ['cancel', true, 'Job R-1'],
371
+ ['delete', { delete: true }, 'Job C-1'],
372
+ ['clear', { clear: true }, '2 finished jobs'],
373
+ ] as const)('should run %s only after the confirm dialog is confirmed when confirm is %o', async (kind, confirm, named) => {
374
+ const source = confirmSource()
375
+ const wrapper = await openTray({ source, confirm })
376
+
377
+ await wrapper.get(triggers[kind]).trigger('click')
378
+ await nextTick()
379
+ const shown = dialog()
380
+ const before = source[kind].mock.calls.length
381
+ const confirmButton = [...shown!.querySelectorAll('button')].at(-1)!
382
+ confirmButton.click()
383
+ await flushPromises()
384
+
385
+ expect([shown?.textContent?.includes(named), before, source[kind].mock.calls.length]).toEqual([true, 0, 1])
386
+ wrapper.unmount()
387
+ })
388
+
389
+ it.each([
390
+ ['off', undefined],
391
+ ['set for another action', { delete: true }],
392
+ ] as const)('should cancel at once without a dialog when confirm is %s', async (_case, confirm) => {
393
+ const source = confirmSource()
394
+ const wrapper = await openTray({ source, confirm })
395
+
396
+ await wrapper.get(triggers.cancel).trigger('click')
397
+ await flushPromises()
398
+
399
+ expect([dialog(), source.cancel.mock.calls.length]).toEqual([null, 1])
400
+ wrapper.unmount()
401
+ })
402
+
403
+ it('should run the action once when Confirm is clicked twice while the dialog fades out', async () => {
404
+ const source = confirmSource()
405
+ const wrapper = await openTray({ source, confirm: true, transitions: true })
406
+ await wrapper.get(triggers.clear).trigger('click')
407
+ await nextTick()
408
+ const confirmButton = [...dialog()!.querySelectorAll('button')].at(-1)!
409
+
410
+ confirmButton.click()
411
+ confirmButton.click()
412
+ await flushPromises()
413
+
414
+ expect([confirmButton.isConnected, source.clear.mock.calls.length]).toEqual([true, 1])
415
+ await vi.waitFor(() => expect(dialog()).toBeNull())
416
+ wrapper.unmount()
417
+ })
418
+
419
+ it('should close only the dialog when Escape is pressed in it', async () => {
420
+ const source = confirmSource()
421
+ const wrapper = await openTray({ source, confirm: true })
422
+ await wrapper.get(triggers.delete).trigger('click')
423
+ await nextTick()
424
+
425
+ dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
426
+ await flushPromises()
427
+
428
+ expect([dialog(), wrapper.find('.mint-jobs-tray__panel').exists(), source.delete.mock.calls.length])
429
+ .toEqual([null, true, 0])
430
+ wrapper.unmount()
431
+ })
432
+
433
+ it('should ask again with the new count when the finished jobs change before Confirm', async () => {
434
+ const jobs = shallowRef([job('completed', 'C-1'), job('failed', 'F-1')])
435
+ const clear = vi.fn(async () => 3)
436
+ const wrapper = await openTray({ source: { jobs, clear }, confirm: true })
437
+ await wrapper.get(triggers.clear).trigger('click')
438
+ await nextTick()
439
+
440
+ jobs.value = [...jobs.value, job('cancelled', 'X-1')]
441
+ await nextTick()
442
+ ;[...dialog()!.querySelectorAll('button')].at(-1)!.click()
443
+ await flushPromises()
444
+ const asked = [dialog()?.textContent?.includes('3 finished jobs'), clear.mock.calls.length]
445
+ ;[...dialog()!.querySelectorAll('button')].at(-1)!.click()
446
+ await flushPromises()
447
+
448
+ expect([...asked, dialog(), clear.mock.calls.length]).toEqual([true, 0, null, 1])
449
+ wrapper.unmount()
450
+ })
451
+
452
+ it.each([
453
+ ['delete', 'the job is gone', [] as JobStateInput[]],
454
+ ['cancel', 'the job finished', [{ ...job('completed', 'R-1'), can_cancel: true }]],
455
+ ['delete', 'the job is no longer deletable', [{ ...job('completed', 'C-1'), can_delete: false }]],
456
+ ] as const)('should close the %s dialog without acting when %s before Confirm', async (kind, _case, next) => {
457
+ const source = { ...confirmSource(), jobs: shallowRef<JobStateInput[]>(confirmSource().jobs) }
458
+ const wrapper = await openTray({ source, confirm: true })
459
+ await wrapper.get(triggers[kind]).trigger('click')
460
+ await nextTick()
461
+
462
+ source.jobs.value = [...next]
463
+ await nextTick()
464
+ ;[...dialog()!.querySelectorAll('button')].at(-1)!.click()
465
+ await flushPromises()
466
+
467
+ expect([dialog(), source[kind].mock.calls.length]).toEqual([null, 0])
468
+ wrapper.unmount()
469
+ })
470
+
471
+ it('should drop the pending action when the job source changes while the dialog is open', async () => {
472
+ const first = confirmSource()
473
+ const second = confirmSource()
474
+ const current = shallowRef<PluginJobCenterSource>(first)
475
+ const Host = defineComponent({
476
+ setup() {
477
+ const center = useJobCenter({ source: current, loadOnMount: false })
478
+ center.open()
479
+ return () => h(JobsStatusTray, { center, teleportTo: false, confirm: true })
480
+ },
481
+ })
482
+ const wrapper = mount(Host, { attachTo: document.body })
483
+ await nextTick()
484
+ await wrapper.get(triggers.delete).trigger('click')
485
+ await nextTick()
486
+
487
+ current.value = second
488
+ await nextTick()
489
+
490
+ expect([dialog(), first.delete.mock.calls.length, second.delete.mock.calls.length]).toEqual([null, 0, 0])
491
+ wrapper.unmount()
492
+ })
493
+ })
258
494
  })
@@ -423,6 +423,21 @@ describe('useJobCenter top-bar chip', () => {
423
423
  }).toEqual({ baseline: null, later: { status: 'failed', label: '1 failed' }, stored: ['old'] })
424
424
  })
425
425
 
426
+ it.each<[string, TopBarJob | null, TopBarJob]>([
427
+ ['use the override', { status: 'completed', label: 'Saved to the experiment' },
428
+ { status: 'completed', label: 'Saved to the experiment' }],
429
+ ['keep the default', null, { status: 'completed', label: 'r completed' }],
430
+ ])('should %s for the held job when the topBarJob option returns that value', async (_case, override, expected) => {
431
+ const jobs = shallowRef([job('running', 'r')])
432
+ const topBarJob = vi.fn((shown: { status: JobStatus }) => (shown.status === 'completed' ? override : null))
433
+ const { tray } = mountComposable({ source: { jobs }, loadOnMount: false, topBarJob })
434
+
435
+ jobs.value = [job('completed', 'r')]
436
+ await nextTick()
437
+
438
+ expect(tray.topBarJob.value).toEqual(expected)
439
+ })
440
+
426
441
  it('should hold a just-completed job for 6 s and select it on click when nothing else runs', async () => {
427
442
  vi.useFakeTimers()
428
443
  const jobs = shallowRef([job('running', 'r')])
@@ -501,3 +516,27 @@ describe('useJobCenter top-bar chip', () => {
501
516
  expect(tray.completedCount.value).toBe(2)
502
517
  })
503
518
  })
519
+
520
+ describe('useJobCenter row view', () => {
521
+ const awaiting = job('awaiting_input', 'a')
522
+ const running = job('running', 'r')
523
+
524
+ it.each<[string, JobStateInput, UseJobCenterOptions['rowJob'], ReturnType<ReturnType<typeof useJobCenter>['rowView']>]>([
525
+ ['absent', running, undefined, { status: 'running', percent: 42, message: 'running' }],
526
+ ['null', awaiting, () => null,
527
+ { status: 'awaiting_input', percent: null, message: 'Waiting for external input' }],
528
+ ['a status and percent', awaiting, () => ({ status: 'running', percent: 40 }),
529
+ { status: 'running', percent: 40, message: 'Waiting for external input' }],
530
+ ['a message only', running, () => ({ message: 'Decoding 3/8' }),
531
+ { status: 'running', percent: 42, message: 'Decoding 3/8' }],
532
+ ['undefined and null fields', running, () => ({ status: null, percent: undefined, message: null }),
533
+ { status: 'running', percent: 42, message: 'running' }],
534
+ ['a status only', running, () => ({ status: 'awaiting_input' }),
535
+ { status: 'awaiting_input', percent: 42, message: 'running' }],
536
+ ['a null percent', running, () => ({ percent: null }),
537
+ { status: 'running', percent: null, message: 'running' }],
538
+ ])('should resolve the row view from the job when the row override is %s', (_case, input, rowJob, expected) => {
539
+ const { tray } = mountComposable({ source: { jobs: [input] }, loadOnMount: false, rowJob })
540
+ expect(tray.rowView(tray.jobs.value[0]!)).toEqual(expected)
541
+ })
542
+ })
@@ -272,6 +272,7 @@ describe('frontend docs catalog coverage', () => {
272
272
 
273
273
  expect(jobsStatusTray?.props?.map(prop => prop.name).sort()).toEqual([
274
274
  'center',
275
+ 'confirm',
275
276
  'teleportTo',
276
277
  'title',
277
278
  ])
@@ -63,11 +63,18 @@ const TrayDemo = defineComponent({
63
63
  jobs: { type: Array as PropType<JobStateInput[]>, required: true },
64
64
  title: { type: String, default: undefined },
65
65
  open: Boolean,
66
+ confirm: Boolean,
67
+ /** Show the awaiting-input job as a browser decode, as LEAF does. */
68
+ decode: Boolean,
66
69
  },
67
70
  setup(props) {
68
71
  // One source object: a new source identity resets the just-completed hold.
69
72
  const center = useJobCenter({
70
73
  source: { jobs: () => props.jobs, cancel: cancelJob, delete: deleteJob },
74
+ rowJob: row => (props.decode && row.status === 'awaiting_input'
75
+ ? { status: 'running', percent: 62, message: 'Decoding in the browser · 5/8' }
76
+ : null),
77
+ topBarJob: shown => (shown.status === 'completed' ? { status: 'completed', label: `Saved to ${shown.experiment_id}` } : null),
71
78
  })
72
79
  onMounted(() => { if (props.open) center.open() })
73
80
  return () => [
@@ -79,7 +86,7 @@ const TrayDemo = defineComponent({
79
86
  jobsPanelId: center.panelId,
80
87
  onJobClick: () => center.handleTopBarClick(openJob),
81
88
  }),
82
- h(JobsStatusTray, { center, title: props.title, onSelect: openJob }, {
89
+ h(JobsStatusTray, { center, title: props.title, confirm: props.confirm, onSelect: openJob }, {
83
90
  'job-actions': ({ job: row }: { job: JobState }) => row.status === 'completed'
84
91
  ? h('button', { type: 'button', class: 'mint-jobs-tray__section-action', onClick: () => openJob(row) }, 'Open')
85
92
  : null,
@@ -109,6 +116,8 @@ function initState() {
109
116
  percent: 37,
110
117
  finished: false,
111
118
  open: true,
119
+ confirm: false,
120
+ decode: false,
112
121
  }
113
122
  }
114
123
  </script>
@@ -123,6 +132,8 @@ function initState() {
123
132
  :jobs="playgroundJobs(state.percent, state.finished)"
124
133
  :title="state.title"
125
134
  :open="state.open"
135
+ :confirm="state.confirm"
136
+ :decode="state.decode"
126
137
  />
127
138
  </div>
128
139
  </template>
@@ -131,9 +142,17 @@ function initState() {
131
142
  <HstSlider v-model="state.percent" title="Running percent" :min="0" :max="100" />
132
143
  <HstCheckbox v-model="state.finished" title="Running job completed (6 s chip)" />
133
144
  <HstCheckbox v-model="state.open" title="Open on mount" />
145
+ <HstCheckbox v-model="state.confirm" title="Confirm cancel, delete and clear" />
146
+ <HstCheckbox v-model="state.decode" title="Row override: J-104 as a browser decode" />
134
147
  </template>
135
148
  </Variant>
136
149
 
150
+ <Variant title="Confirm and row override">
151
+ <div style="min-height: 560px;">
152
+ <TrayDemo :jobs="playgroundJobs(37, false)" open confirm decode />
153
+ </div>
154
+ </Variant>
155
+
137
156
  <Variant title="Awaiting input">
138
157
  <div style="min-height: 560px;">
139
158
  <TrayDemo :jobs="[job('awaiting_input', 'J-301')]" open />