@morscherlab/mint-sdk 1.0.56 → 1.0.57
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.
- package/dist/__tests__/components/ArtifactSaveDialog.test.d.ts +1 -0
- package/dist/__tests__/components/ArtifactSelectorModal.test.d.ts +1 -0
- package/dist/__tests__/composables/useAnalysisArtifacts.test.d.ts +1 -0
- package/dist/analysisArtifactTypes-Cu40dzSG.js +7 -0
- package/dist/analysisArtifactTypes-Cu40dzSG.js.map +1 -0
- package/dist/components/ArtifactSaveDialog.vue.d.ts +56 -0
- package/dist/components/ArtifactSelectorModal.vue.d.ts +41 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-BQwOeyiy.js → components-PC3SVsaa.js} +1743 -1144
- package/dist/components-PC3SVsaa.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +4 -3
- package/dist/composables/useAnalysisArtifacts.d.ts +59 -0
- package/dist/{composables-TpXyhRZZ.js → composables-OYTD0Y3r.js} +2 -2
- package/dist/{composables-TpXyhRZZ.js.map → composables-OYTD0Y3r.js.map} +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/install.js +1 -1
- package/dist/styles.css +412 -0
- package/dist/types/analysisArtifactTypes.d.ts +100 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/{useJobsStatusTray-CVecN6Ao.js → useAnalysisArtifacts-Ob8UtVwl.js} +157 -2
- package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/ArtifactSaveDialog.test.ts +516 -0
- package/src/__tests__/components/ArtifactSelectorModal.test.ts +178 -0
- package/src/__tests__/composables/useAnalysisArtifacts.test.ts +667 -0
- package/src/components/ArtifactSaveDialog.story.vue +293 -0
- package/src/components/ArtifactSaveDialog.vue +383 -0
- package/src/components/ArtifactSelectorModal.story.vue +295 -0
- package/src/components/ArtifactSelectorModal.vue +326 -0
- package/src/components/index.ts +2 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useAnalysisArtifacts.ts +305 -0
- package/src/styles/components/artifact-save-dialog.css +17 -0
- package/src/styles/components/artifact-selector-modal.css +216 -0
- package/src/styles/index.css +2 -0
- package/src/types/analysisArtifactTypes.ts +119 -0
- package/src/types/index.ts +17 -0
- package/dist/components-BQwOeyiy.js.map +0 -1
- package/dist/useJobsStatusTray-CVecN6Ao.js.map +0 -1
- /package/dist/{ExperimentPopover-8A4Rhffp.js → ExperimentPopover-Ryusjrhv.js} +0 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { ref, nextTick } from 'vue'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Mock useApi — intercepts all HTTP calls
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const mockGet = vi.fn()
|
|
9
|
+
const mockPost = vi.fn()
|
|
10
|
+
const mockPatch = vi.fn()
|
|
11
|
+
const mockUseApi = vi.fn((_options?: unknown) => ({
|
|
12
|
+
get: mockGet,
|
|
13
|
+
post: mockPost,
|
|
14
|
+
patch: mockPatch,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
vi.mock('../../composables/useApi', () => ({
|
|
18
|
+
useApi: (options: unknown) => mockUseApi(options),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import { useAnalysisArtifacts } from '../../composables/useAnalysisArtifacts'
|
|
22
|
+
import type { AnalysisArtifactSummary } from '../../types/analysisArtifactTypes'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Fixtures
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
function makeArtifact(
|
|
29
|
+
overrides: Partial<AnalysisArtifactSummary> = {},
|
|
30
|
+
): AnalysisArtifactSummary {
|
|
31
|
+
return {
|
|
32
|
+
id: 1,
|
|
33
|
+
experiment_id: 42,
|
|
34
|
+
plugin_id: 'plugin-a',
|
|
35
|
+
artifact_key: 'default',
|
|
36
|
+
display_name: 'Dose Response Fit',
|
|
37
|
+
status: 'active',
|
|
38
|
+
result_keys: ['summary', 'tree'],
|
|
39
|
+
created_at: '2026-07-01T10:00:00Z',
|
|
40
|
+
updated_at: '2026-07-02T10:00:00Z',
|
|
41
|
+
...overrides,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ARTIFACTS = [
|
|
46
|
+
makeArtifact({ id: 1, plugin_id: 'plugin-a', artifact_key: 'fit', display_name: 'Dose Response Fit' }),
|
|
47
|
+
makeArtifact({ id: 2, plugin_id: 'plugin-b', artifact_key: 'peaks', display_name: 'Peak Table' }),
|
|
48
|
+
makeArtifact({
|
|
49
|
+
id: 3,
|
|
50
|
+
plugin_id: 'plugin-a',
|
|
51
|
+
artifact_key: 'old-fit',
|
|
52
|
+
display_name: 'Old Fit',
|
|
53
|
+
status: 'archived',
|
|
54
|
+
archived_at: '2026-07-03T10:00:00Z',
|
|
55
|
+
}),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
function setup(options?: Parameters<typeof useAnalysisArtifacts>[0]) {
|
|
59
|
+
return useAnalysisArtifacts({ experimentId: 42, ...options })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Let watchers fire and their triggered requests settle. */
|
|
63
|
+
async function flush(): Promise<void> {
|
|
64
|
+
await nextTick()
|
|
65
|
+
await Promise.resolve()
|
|
66
|
+
await Promise.resolve()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Tests
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
describe('useAnalysisArtifacts', () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
vi.clearAllMocks()
|
|
76
|
+
mockGet.mockResolvedValue({ artifacts: ARTIFACTS })
|
|
77
|
+
mockPost.mockResolvedValue(makeArtifact())
|
|
78
|
+
mockPatch.mockResolvedValue(makeArtifact())
|
|
79
|
+
delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
|
|
80
|
+
window.history.replaceState({}, '', '/')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// -------------------------------------------------------------------------
|
|
84
|
+
// list
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('list', () => {
|
|
88
|
+
it('should call GET /experiments/{id}/analysis-artifacts with include_archived=false by default', async () => {
|
|
89
|
+
const { list } = setup()
|
|
90
|
+
|
|
91
|
+
await list()
|
|
92
|
+
|
|
93
|
+
expect(mockGet).toHaveBeenCalledOnce()
|
|
94
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=false')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should pass include_archived=true when includeArchived option is set', async () => {
|
|
98
|
+
const { list } = setup({ includeArchived: true })
|
|
99
|
+
|
|
100
|
+
await list()
|
|
101
|
+
|
|
102
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=true')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should store and return the fetched artifacts', async () => {
|
|
106
|
+
const { list, artifacts } = setup()
|
|
107
|
+
|
|
108
|
+
const result = await list()
|
|
109
|
+
|
|
110
|
+
expect(result).toHaveLength(3)
|
|
111
|
+
expect(artifacts.value).toEqual(ARTIFACTS)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('should filter to the given pluginId client-side', async () => {
|
|
115
|
+
const { list, artifacts } = setup({ pluginId: 'plugin-a' })
|
|
116
|
+
|
|
117
|
+
await list()
|
|
118
|
+
|
|
119
|
+
expect(artifacts.value.map((a) => a.id)).toEqual([1, 3])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('should keep all plugins when pluginId is not provided', async () => {
|
|
123
|
+
const { list, artifacts } = setup()
|
|
124
|
+
|
|
125
|
+
await list()
|
|
126
|
+
|
|
127
|
+
expect(new Set(artifacts.value.map((a) => a.plugin_id))).toEqual(
|
|
128
|
+
new Set(['plugin-a', 'plugin-b']),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('should set error and skip the request when no experiment id is available', async () => {
|
|
133
|
+
const { list, error } = useAnalysisArtifacts()
|
|
134
|
+
|
|
135
|
+
const result = await list()
|
|
136
|
+
|
|
137
|
+
expect(result).toEqual([])
|
|
138
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
139
|
+
expect(error.value).toContain('experiment')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should reset artifacts and set error on failure', async () => {
|
|
143
|
+
const { list, artifacts, error } = setup()
|
|
144
|
+
await list()
|
|
145
|
+
expect(artifacts.value).toHaveLength(3)
|
|
146
|
+
|
|
147
|
+
mockGet.mockRejectedValueOnce(new Error('Network error'))
|
|
148
|
+
const result = await list()
|
|
149
|
+
|
|
150
|
+
expect(result).toEqual([])
|
|
151
|
+
expect(artifacts.value).toEqual([])
|
|
152
|
+
expect(error.value).toBe('Network error')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should toggle isLoading during the call', async () => {
|
|
156
|
+
let resolveGet!: (v: unknown) => void
|
|
157
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r }))
|
|
158
|
+
|
|
159
|
+
const { list, isLoading } = setup()
|
|
160
|
+
expect(isLoading.value).toBe(false)
|
|
161
|
+
|
|
162
|
+
const promise = list()
|
|
163
|
+
expect(isLoading.value).toBe(true)
|
|
164
|
+
|
|
165
|
+
resolveGet({ artifacts: [] })
|
|
166
|
+
await promise
|
|
167
|
+
expect(isLoading.value).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should update lastLoadedAt on success', async () => {
|
|
171
|
+
const { list, lastLoadedAt } = setup()
|
|
172
|
+
expect(lastLoadedAt.value).toBeNull()
|
|
173
|
+
|
|
174
|
+
await list()
|
|
175
|
+
|
|
176
|
+
expect(lastLoadedAt.value).toBeInstanceOf(Date)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// experiment id resolution
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
describe('experiment id resolution', () => {
|
|
185
|
+
it('should accept a ref experiment id', async () => {
|
|
186
|
+
const experimentId = ref<number | undefined>(7)
|
|
187
|
+
const { list } = useAnalysisArtifacts({ experimentId })
|
|
188
|
+
|
|
189
|
+
await list()
|
|
190
|
+
|
|
191
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/7/analysis-artifacts?include_archived=false')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should accept a getter experiment id', async () => {
|
|
195
|
+
const { list } = useAnalysisArtifacts({ experimentId: () => 9 })
|
|
196
|
+
|
|
197
|
+
await list()
|
|
198
|
+
|
|
199
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/9/analysis-artifacts?include_archived=false')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('should fall back to the injected platform context', async () => {
|
|
203
|
+
;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
|
|
204
|
+
currentExperimentId: 33,
|
|
205
|
+
}
|
|
206
|
+
const { list } = useAnalysisArtifacts()
|
|
207
|
+
|
|
208
|
+
await list()
|
|
209
|
+
|
|
210
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/33/analysis-artifacts?include_archived=false')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should fall back to the URL experiment id', async () => {
|
|
214
|
+
window.history.replaceState({}, '', '/?experimentId=55')
|
|
215
|
+
const { list } = useAnalysisArtifacts()
|
|
216
|
+
|
|
217
|
+
await list()
|
|
218
|
+
|
|
219
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/55/analysis-artifacts?include_archived=false')
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// -------------------------------------------------------------------------
|
|
224
|
+
// visibleArtifacts filtering
|
|
225
|
+
// -------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
describe('visibleArtifacts', () => {
|
|
228
|
+
it('should show only active artifacts by default', async () => {
|
|
229
|
+
const { list, visibleArtifacts } = setup({ includeArchived: true })
|
|
230
|
+
|
|
231
|
+
await list()
|
|
232
|
+
|
|
233
|
+
expect(visibleArtifacts.value.map((a) => a.id)).toEqual([1, 2])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('should show archived artifacts when statusFilter is "archived"', async () => {
|
|
237
|
+
const { list, visibleArtifacts, statusFilter } = setup({ includeArchived: true })
|
|
238
|
+
await list()
|
|
239
|
+
|
|
240
|
+
statusFilter.value = 'archived'
|
|
241
|
+
|
|
242
|
+
expect(visibleArtifacts.value.map((a) => a.id)).toEqual([3])
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should show everything when statusFilter is "all"', async () => {
|
|
246
|
+
const { list, visibleArtifacts, statusFilter } = setup({ includeArchived: true })
|
|
247
|
+
await list()
|
|
248
|
+
|
|
249
|
+
statusFilter.value = 'all'
|
|
250
|
+
|
|
251
|
+
expect(visibleArtifacts.value).toHaveLength(3)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('should refetch with include_archived=true when statusFilter widens past "active"', async () => {
|
|
255
|
+
const { list, statusFilter } = setup()
|
|
256
|
+
await list()
|
|
257
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=false')
|
|
258
|
+
|
|
259
|
+
statusFilter.value = 'all'
|
|
260
|
+
await nextTick()
|
|
261
|
+
await Promise.resolve()
|
|
262
|
+
|
|
263
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should filter by search over display_name and artifact_key (case-insensitive)', async () => {
|
|
267
|
+
const { list, visibleArtifacts, search } = setup()
|
|
268
|
+
await list()
|
|
269
|
+
|
|
270
|
+
search.value = 'PEAK'
|
|
271
|
+
expect(visibleArtifacts.value.map((a) => a.id)).toEqual([2])
|
|
272
|
+
|
|
273
|
+
search.value = 'fit'
|
|
274
|
+
expect(visibleArtifacts.value.map((a) => a.id)).toEqual([1])
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('should filter by pluginFilter', async () => {
|
|
278
|
+
const { list, visibleArtifacts, pluginFilter } = setup()
|
|
279
|
+
await list()
|
|
280
|
+
|
|
281
|
+
pluginFilter.value = 'plugin-b'
|
|
282
|
+
|
|
283
|
+
expect(visibleArtifacts.value.map((a) => a.id)).toEqual([2])
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// -------------------------------------------------------------------------
|
|
288
|
+
// getDetail
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
describe('getDetail', () => {
|
|
292
|
+
it('should GET the artifact detail and store it in selectedDetail', async () => {
|
|
293
|
+
const detail = { ...makeArtifact(), result: { x: 1 }, tree: [], summary: null }
|
|
294
|
+
mockGet.mockResolvedValueOnce(detail)
|
|
295
|
+
const { getDetail, selectedDetail } = setup()
|
|
296
|
+
|
|
297
|
+
const result = await getDetail(1)
|
|
298
|
+
|
|
299
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1')
|
|
300
|
+
expect(result).toEqual(detail)
|
|
301
|
+
expect(selectedDetail.value).toEqual(detail)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('should return null and set error on failure', async () => {
|
|
305
|
+
mockGet.mockRejectedValueOnce(new Error('Not found'))
|
|
306
|
+
const { getDetail, selectedDetail, error } = setup()
|
|
307
|
+
|
|
308
|
+
const result = await getDetail(99)
|
|
309
|
+
|
|
310
|
+
expect(result).toBeNull()
|
|
311
|
+
expect(selectedDetail.value).toBeNull()
|
|
312
|
+
expect(error.value).toBe('Not found')
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
// mutations
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
describe('updateMetadata', () => {
|
|
321
|
+
it('should PATCH the metadata and splice the returned summary into artifacts', async () => {
|
|
322
|
+
const { list, updateMetadata, artifacts } = setup()
|
|
323
|
+
await list()
|
|
324
|
+
|
|
325
|
+
const renamed = makeArtifact({ id: 1, artifact_key: 'fit', display_name: 'Renamed' })
|
|
326
|
+
mockPatch.mockResolvedValueOnce(renamed)
|
|
327
|
+
|
|
328
|
+
const updated = await updateMetadata(1, { display_name: 'Renamed' })
|
|
329
|
+
|
|
330
|
+
expect(updated).toMatchObject({ id: 1, display_name: 'Renamed' })
|
|
331
|
+
expect(mockPatch).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1', {
|
|
332
|
+
display_name: 'Renamed',
|
|
333
|
+
})
|
|
334
|
+
expect(artifacts.value.find((a) => a.id === 1)?.display_name).toBe('Renamed')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('should return null and set error on failure', async () => {
|
|
338
|
+
mockPatch.mockRejectedValueOnce(new Error('Forbidden'))
|
|
339
|
+
const { updateMetadata, error } = setup()
|
|
340
|
+
|
|
341
|
+
const updated = await updateMetadata(1, { note: 'x' })
|
|
342
|
+
|
|
343
|
+
expect(updated).toBeNull()
|
|
344
|
+
expect(error.value).toBe('Forbidden')
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('archive / restore', () => {
|
|
349
|
+
it('should POST archive and splice the returned summary', async () => {
|
|
350
|
+
const { list, archive, artifacts } = setup()
|
|
351
|
+
await list()
|
|
352
|
+
|
|
353
|
+
const archived = makeArtifact({ id: 1, artifact_key: 'fit', status: 'archived' })
|
|
354
|
+
mockPost.mockResolvedValueOnce(archived)
|
|
355
|
+
|
|
356
|
+
const result = await archive(1)
|
|
357
|
+
|
|
358
|
+
// Returns the post-mutation summary so callers never report a stale status.
|
|
359
|
+
expect(result).toMatchObject({ id: 1, status: 'archived' })
|
|
360
|
+
expect(mockPost).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/1/archive')
|
|
361
|
+
expect(artifacts.value.find((a) => a.id === 1)?.status).toBe('archived')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('should POST restore and splice the returned summary', async () => {
|
|
365
|
+
const { list, restore, artifacts } = setup({ includeArchived: true })
|
|
366
|
+
await list()
|
|
367
|
+
|
|
368
|
+
const restored = makeArtifact({ id: 3, artifact_key: 'old-fit', status: 'active' })
|
|
369
|
+
mockPost.mockResolvedValueOnce(restored)
|
|
370
|
+
|
|
371
|
+
const result = await restore(3)
|
|
372
|
+
|
|
373
|
+
expect(result).toMatchObject({ id: 3, status: 'active' })
|
|
374
|
+
expect(mockPost).toHaveBeenCalledWith('/experiments/42/analysis-artifacts/3/restore')
|
|
375
|
+
expect(artifacts.value.find((a) => a.id === 3)?.status).toBe('active')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('should keep isMutating true until every mutation settles', async () => {
|
|
379
|
+
let resolveFirst!: (v: unknown) => void
|
|
380
|
+
mockPost.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
|
|
381
|
+
let resolveSecond!: (v: unknown) => void
|
|
382
|
+
mockPost.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
|
|
383
|
+
|
|
384
|
+
const { archive, isMutating } = setup()
|
|
385
|
+
const first = archive(1)
|
|
386
|
+
const second = archive(2)
|
|
387
|
+
|
|
388
|
+
resolveSecond(makeArtifact({ id: 2, status: 'archived' }))
|
|
389
|
+
await second
|
|
390
|
+
expect(isMutating.value).toBe(true)
|
|
391
|
+
|
|
392
|
+
resolveFirst(makeArtifact({ id: 1, status: 'archived' }))
|
|
393
|
+
await first
|
|
394
|
+
expect(isMutating.value).toBe(false)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('should toggle isMutating during archive', async () => {
|
|
398
|
+
let resolvePost!: (v: unknown) => void
|
|
399
|
+
mockPost.mockImplementationOnce(() => new Promise((r) => { resolvePost = r }))
|
|
400
|
+
|
|
401
|
+
const { archive, isMutating } = setup()
|
|
402
|
+
expect(isMutating.value).toBe(false)
|
|
403
|
+
|
|
404
|
+
const promise = archive(1)
|
|
405
|
+
expect(isMutating.value).toBe(true)
|
|
406
|
+
|
|
407
|
+
resolvePost(makeArtifact())
|
|
408
|
+
await promise
|
|
409
|
+
expect(isMutating.value).toBe(false)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('should return null on archive failure', async () => {
|
|
413
|
+
mockPost.mockRejectedValueOnce(new Error('Conflict'))
|
|
414
|
+
const { archive, error } = setup()
|
|
415
|
+
|
|
416
|
+
const result = await archive(1)
|
|
417
|
+
|
|
418
|
+
expect(result).toBeNull()
|
|
419
|
+
expect(error.value).toBe('Conflict')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// -------------------------------------------------------------------------
|
|
424
|
+
// helpers
|
|
425
|
+
// -------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
describe('hasArtifactKey', () => {
|
|
428
|
+
it('should report whether a key exists in the loaded artifacts', async () => {
|
|
429
|
+
const { list, hasArtifactKey } = setup()
|
|
430
|
+
await list()
|
|
431
|
+
|
|
432
|
+
expect(hasArtifactKey('fit')).toBe(true)
|
|
433
|
+
expect(hasArtifactKey('nope')).toBe(false)
|
|
434
|
+
})
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
describe('clear', () => {
|
|
438
|
+
it('should reset artifacts, selectedDetail, and error', async () => {
|
|
439
|
+
mockGet.mockRejectedValueOnce(new Error('boom'))
|
|
440
|
+
const { list, clear, artifacts, selectedDetail, error } = setup()
|
|
441
|
+
await list()
|
|
442
|
+
expect(error.value).toBe('boom')
|
|
443
|
+
|
|
444
|
+
clear()
|
|
445
|
+
|
|
446
|
+
expect(artifacts.value).toEqual([])
|
|
447
|
+
expect(selectedDetail.value).toBeNull()
|
|
448
|
+
expect(error.value).toBeNull()
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
describe('immediate', () => {
|
|
453
|
+
it('should fetch on creation when immediate is set and an experiment id exists', async () => {
|
|
454
|
+
setup({ immediate: true })
|
|
455
|
+
|
|
456
|
+
await Promise.resolve()
|
|
457
|
+
await Promise.resolve()
|
|
458
|
+
|
|
459
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/42/analysis-artifacts?include_archived=false')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('should not fetch on creation without immediate', async () => {
|
|
463
|
+
setup()
|
|
464
|
+
|
|
465
|
+
await Promise.resolve()
|
|
466
|
+
|
|
467
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('should fetch once a late-resolving experiment id becomes available', async () => {
|
|
471
|
+
const experimentId = ref<number | undefined>(undefined)
|
|
472
|
+
useAnalysisArtifacts({ experimentId, immediate: true })
|
|
473
|
+
|
|
474
|
+
await flush()
|
|
475
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
476
|
+
|
|
477
|
+
experimentId.value = 77
|
|
478
|
+
await flush()
|
|
479
|
+
|
|
480
|
+
expect(mockGet).toHaveBeenCalledWith('/experiments/77/analysis-artifacts?include_archived=false')
|
|
481
|
+
})
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
// -------------------------------------------------------------------------
|
|
485
|
+
// Reactivity
|
|
486
|
+
// -------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
describe('reactive options', () => {
|
|
489
|
+
it('should refetch when the experiment id changes after a load', async () => {
|
|
490
|
+
const experimentId = ref<number | undefined>(42)
|
|
491
|
+
const { list } = useAnalysisArtifacts({ experimentId })
|
|
492
|
+
await list()
|
|
493
|
+
|
|
494
|
+
experimentId.value = 43
|
|
495
|
+
await flush()
|
|
496
|
+
|
|
497
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/43/analysis-artifacts?include_archived=false')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('should re-scope artifacts when a reactive pluginId changes without refetching', async () => {
|
|
501
|
+
const pluginId = ref<string | null>('plugin-a')
|
|
502
|
+
const { list, artifacts } = useAnalysisArtifacts({ experimentId: 42, pluginId })
|
|
503
|
+
await list()
|
|
504
|
+
expect(artifacts.value.map((a) => a.id)).toEqual([1, 3])
|
|
505
|
+
|
|
506
|
+
pluginId.value = 'plugin-b'
|
|
507
|
+
await flush()
|
|
508
|
+
|
|
509
|
+
expect(artifacts.value.map((a) => a.id)).toEqual([2])
|
|
510
|
+
expect(mockGet).toHaveBeenCalledOnce()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('should refetch with include_archived=true when a reactive includeArchived flips on', async () => {
|
|
514
|
+
const includeArchived = ref(false)
|
|
515
|
+
const { list } = useAnalysisArtifacts({ experimentId: 42, includeArchived })
|
|
516
|
+
await list()
|
|
517
|
+
|
|
518
|
+
includeArchived.value = true
|
|
519
|
+
await flush()
|
|
520
|
+
|
|
521
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true')
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('should not fetch when statusFilter widens before anything requested a list', async () => {
|
|
525
|
+
const { statusFilter } = setup()
|
|
526
|
+
|
|
527
|
+
statusFilter.value = 'archived'
|
|
528
|
+
await flush()
|
|
529
|
+
|
|
530
|
+
expect(mockGet).not.toHaveBeenCalled()
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('should refetch when statusFilter widens while the first list is still in flight', async () => {
|
|
534
|
+
let resolveGet!: (v: unknown) => void
|
|
535
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r }))
|
|
536
|
+
|
|
537
|
+
const { list, statusFilter } = setup()
|
|
538
|
+
const pending = list()
|
|
539
|
+
|
|
540
|
+
statusFilter.value = 'archived'
|
|
541
|
+
await flush()
|
|
542
|
+
|
|
543
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true')
|
|
544
|
+
|
|
545
|
+
resolveGet({ artifacts: [] })
|
|
546
|
+
await pending
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it('should refetch when statusFilter widens after a failed load', async () => {
|
|
550
|
+
mockGet.mockRejectedValueOnce(new Error('Network error'))
|
|
551
|
+
const { list, statusFilter } = setup()
|
|
552
|
+
await list()
|
|
553
|
+
|
|
554
|
+
statusFilter.value = 'archived'
|
|
555
|
+
await flush()
|
|
556
|
+
|
|
557
|
+
expect(mockGet).toHaveBeenLastCalledWith('/experiments/42/analysis-artifacts?include_archived=true')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('should clear artifacts and selection when the experiment id goes away', async () => {
|
|
561
|
+
const experimentId = ref<number | undefined>(42)
|
|
562
|
+
const { list, getDetail, artifacts, selectedDetail } = useAnalysisArtifacts({ experimentId })
|
|
563
|
+
await list()
|
|
564
|
+
mockGet.mockResolvedValueOnce({ ...makeArtifact(), result: {}, tree: [], summary: null })
|
|
565
|
+
await getDetail(1)
|
|
566
|
+
expect(artifacts.value).toHaveLength(3)
|
|
567
|
+
expect(selectedDetail.value).not.toBeNull()
|
|
568
|
+
|
|
569
|
+
experimentId.value = undefined
|
|
570
|
+
await flush()
|
|
571
|
+
|
|
572
|
+
expect(artifacts.value).toEqual([])
|
|
573
|
+
expect(selectedDetail.value).toBeNull()
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
it('should not let a request from a dropped experiment repopulate state', async () => {
|
|
577
|
+
const experimentId = ref<number | undefined>(42)
|
|
578
|
+
let resolveGet!: (v: unknown) => void
|
|
579
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveGet = r }))
|
|
580
|
+
|
|
581
|
+
const { list, artifacts } = useAnalysisArtifacts({ experimentId })
|
|
582
|
+
const pending = list()
|
|
583
|
+
|
|
584
|
+
experimentId.value = undefined
|
|
585
|
+
await flush()
|
|
586
|
+
|
|
587
|
+
resolveGet({ artifacts: ARTIFACTS })
|
|
588
|
+
await pending
|
|
589
|
+
|
|
590
|
+
expect(artifacts.value).toEqual([])
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('should ignore a stale failure that lands after a newer success', async () => {
|
|
594
|
+
let rejectStale!: (e: unknown) => void
|
|
595
|
+
mockGet.mockImplementationOnce(() => new Promise((_r, reject) => { rejectStale = reject }))
|
|
596
|
+
|
|
597
|
+
const { list, artifacts, error } = setup()
|
|
598
|
+
const stalePromise = list()
|
|
599
|
+
|
|
600
|
+
mockGet.mockResolvedValueOnce({ artifacts: ARTIFACTS })
|
|
601
|
+
await list()
|
|
602
|
+
expect(artifacts.value).toHaveLength(3)
|
|
603
|
+
|
|
604
|
+
rejectStale(new Error('Stale network error'))
|
|
605
|
+
await stalePromise
|
|
606
|
+
|
|
607
|
+
expect(error.value).toBeNull()
|
|
608
|
+
expect(artifacts.value).toHaveLength(3)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('should ignore a stale detail response that resolves after a newer one', async () => {
|
|
612
|
+
let resolveStale!: (v: unknown) => void
|
|
613
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveStale = r }))
|
|
614
|
+
|
|
615
|
+
const { getDetail, selectedDetail } = setup()
|
|
616
|
+
const stalePromise = getDetail(1)
|
|
617
|
+
|
|
618
|
+
const fresh = { ...makeArtifact({ id: 2 }), result: { fresh: true }, tree: [], summary: null }
|
|
619
|
+
mockGet.mockResolvedValueOnce(fresh)
|
|
620
|
+
await getDetail(2)
|
|
621
|
+
expect(selectedDetail.value?.id).toBe(2)
|
|
622
|
+
|
|
623
|
+
resolveStale({ ...makeArtifact({ id: 1 }), result: { stale: true }, tree: [], summary: null })
|
|
624
|
+
const staleResult = await stalePromise
|
|
625
|
+
|
|
626
|
+
expect(staleResult).toBeNull()
|
|
627
|
+
expect(selectedDetail.value?.id).toBe(2)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('should keep isLoading true while any request is still in flight', async () => {
|
|
631
|
+
let resolveFirst!: (v: unknown) => void
|
|
632
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveFirst = r }))
|
|
633
|
+
let resolveSecond!: (v: unknown) => void
|
|
634
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveSecond = r }))
|
|
635
|
+
|
|
636
|
+
const { list, isLoading } = setup()
|
|
637
|
+
const first = list()
|
|
638
|
+
const second = list()
|
|
639
|
+
expect(isLoading.value).toBe(true)
|
|
640
|
+
|
|
641
|
+
resolveSecond({ artifacts: [] })
|
|
642
|
+
await second
|
|
643
|
+
resolveFirst({ artifacts: [] })
|
|
644
|
+
await first
|
|
645
|
+
|
|
646
|
+
expect(isLoading.value).toBe(false)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it('should ignore a stale in-flight response that resolves after a newer one', async () => {
|
|
650
|
+
const stale = [makeArtifact({ id: 99, display_name: 'Stale' })]
|
|
651
|
+
let resolveStale!: (v: unknown) => void
|
|
652
|
+
mockGet.mockImplementationOnce(() => new Promise((r) => { resolveStale = r }))
|
|
653
|
+
|
|
654
|
+
const { list, artifacts } = setup()
|
|
655
|
+
const stalePromise = list()
|
|
656
|
+
|
|
657
|
+
mockGet.mockResolvedValueOnce({ artifacts: ARTIFACTS })
|
|
658
|
+
await list()
|
|
659
|
+
expect(artifacts.value).toHaveLength(3)
|
|
660
|
+
|
|
661
|
+
resolveStale({ artifacts: stale })
|
|
662
|
+
await stalePromise
|
|
663
|
+
|
|
664
|
+
expect(artifacts.value.map((a) => a.id)).toEqual([1, 2, 3])
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
})
|