Skip to content

Commit 4dcc988

Browse files
committed
test: cover asset, model and registry stores
1 parent 25e73f3 commit 4dcc988

8 files changed

Lines changed: 1820 additions & 11 deletions

src/stores/assetDownloadStore.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,19 @@ describe('useAssetDownloadStore', () => {
126126
})
127127
})
128128

129+
it('keeps the first placeholder when the same task is tracked twice', () => {
130+
const store = useAssetDownloadStore()
131+
132+
store.trackDownload('task-123', 'checkpoints', 'first.safetensors')
133+
store.trackDownload('task-123', 'loras', 'second.safetensors')
134+
135+
expect(store.downloadList).toHaveLength(1)
136+
expect(store.downloadList[0]).toMatchObject({
137+
modelType: 'checkpoints',
138+
assetName: 'first.safetensors'
139+
})
140+
})
141+
129142
it('handles out-of-order messages where completed arrives before progress', () => {
130143
const store = useAssetDownloadStore()
131144

@@ -179,6 +192,19 @@ describe('useAssetDownloadStore', () => {
179192
expect(store.finishedDownloads[0].status).toBe('completed')
180193
})
181194

195+
it('skips polling when active downloads have fresh progress', async () => {
196+
const store = useAssetDownloadStore()
197+
198+
dispatch(createDownloadMessage({ status: 'running' }))
199+
await vi.advanceTimersByTimeAsync(9_999)
200+
dispatch(createDownloadMessage({ status: 'running', progress: 75 }))
201+
await vi.advanceTimersByTimeAsync(1)
202+
203+
expect(taskService.getTask).not.toHaveBeenCalled()
204+
expect(store.activeDownloads).toHaveLength(1)
205+
expect(store.activeDownloads[0].progress).toBe(75)
206+
})
207+
182208
it('polls and marks failed downloads', async () => {
183209
const store = useAssetDownloadStore()
184210

@@ -311,5 +337,22 @@ describe('useAssetDownloadStore', () => {
311337
expect(store.sessionDownloadCount).toBe(0)
312338
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
313339
})
340+
341+
it('does not acknowledge unrelated completed downloads', () => {
342+
const store = useAssetDownloadStore()
343+
344+
dispatch(
345+
createDownloadMessage({
346+
status: 'completed',
347+
progress: 100,
348+
asset_id: 'asset-456'
349+
})
350+
)
351+
352+
store.acknowledgeAsset('other-asset')
353+
354+
expect(store.sessionDownloadCount).toBe(1)
355+
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
356+
})
314357
})
315358
})
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import { createPinia, setActivePinia } from 'pinia'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import type * as VueUse from '@vueuse/core'
5+
6+
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
7+
import { api } from '@/scripts/api'
8+
import type { TaskId } from '@/platform/tasks/services/taskService'
9+
import { useAssetExportStore } from '@/stores/assetExportStore'
10+
11+
const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted(
12+
() => ({
13+
getExportDownloadUrl: vi.fn(),
14+
getTask: vi.fn(),
15+
toastAdd: vi.fn(),
16+
intervalState: { cb: null as null | (() => void) }
17+
})
18+
)
19+
20+
vi.mock('@vueuse/core', async (importOriginal) => ({
21+
...(await importOriginal<typeof VueUse>()),
22+
useIntervalFn: (cb: () => void) => {
23+
intervalState.cb = cb
24+
return { pause: vi.fn(), resume: vi.fn() }
25+
}
26+
}))
27+
28+
vi.mock('@/scripts/api', () => ({
29+
api: { addEventListener: vi.fn() }
30+
}))
31+
32+
vi.mock('@/platform/assets/services/assetService', () => ({
33+
assetService: { getExportDownloadUrl }
34+
}))
35+
36+
vi.mock('@/platform/tasks/services/taskService', () => ({
37+
taskService: { getTask }
38+
}))
39+
40+
vi.mock('@/platform/updates/common/toastStore', () => ({
41+
useToastStore: () => ({ add: toastAdd })
42+
}))
43+
44+
vi.mock('@/i18n', () => ({
45+
t: (key: string) => key
46+
}))
47+
48+
function wsMessage(
49+
over: Partial<AssetExportWsMessage> = {}
50+
): AssetExportWsMessage {
51+
return {
52+
task_id: 'task-1',
53+
export_name: 'export.zip',
54+
assets_total: 10,
55+
assets_attempted: 5,
56+
assets_failed: 0,
57+
bytes_total: 1000,
58+
bytes_processed: 500,
59+
progress: 0.5,
60+
status: 'running',
61+
...over
62+
}
63+
}
64+
65+
const taskId = (id: string) => id as TaskId
66+
67+
/**
68+
* Build a store and an `emit` bound to the real `asset_export` listener the
69+
* store registers on `api`, so tests drive the state machine through its
70+
* actual entry point rather than a private method.
71+
*/
72+
function setup() {
73+
const store = useAssetExportStore()
74+
const entry = vi
75+
.mocked(api.addEventListener)
76+
.mock.calls.find((c) => c[0] === 'asset_export')
77+
const handler = entry![1] as (e: { detail: AssetExportWsMessage }) => void
78+
const emit = (msg: AssetExportWsMessage) => handler({ detail: msg })
79+
// Run the polling tick that `useIntervalFn` would normally fire, and let its
80+
// async work settle.
81+
const runPoll = async () => {
82+
intervalState.cb?.()
83+
await new Promise((resolve) => setTimeout(resolve, 0))
84+
}
85+
return { store, emit, runPoll }
86+
}
87+
88+
const STALE_AGO_MS = 20_000
89+
90+
beforeEach(() => {
91+
setActivePinia(createPinia())
92+
vi.mocked(api.addEventListener).mockClear()
93+
getExportDownloadUrl
94+
.mockReset()
95+
.mockResolvedValue({ url: 'https://example.com/export.zip' })
96+
getTask.mockReset()
97+
toastAdd.mockReset()
98+
})
99+
100+
describe('assetExportStore', () => {
101+
it('tracks a new export as created and is idempotent', () => {
102+
const { store } = setup()
103+
104+
store.trackExport(taskId('t1'))
105+
store.trackExport(taskId('t1'))
106+
107+
expect(store.exportList).toHaveLength(1)
108+
expect(store.exportList[0].status).toBe('created')
109+
expect(store.hasExports).toBe(true)
110+
expect(store.hasActiveExports).toBe(true)
111+
})
112+
113+
it('separates active from finished exports by status', () => {
114+
const { store, emit } = setup()
115+
116+
emit(wsMessage({ task_id: 'running', status: 'running' }))
117+
emit(
118+
wsMessage({ task_id: 'failed', status: 'failed', export_name: 'f.zip' })
119+
)
120+
121+
expect(store.activeExports.map((e) => e.taskId)).toEqual(['running'])
122+
expect(store.finishedExports.map((e) => e.taskId)).toEqual(['failed'])
123+
})
124+
125+
it('updates an export from successive websocket messages', () => {
126+
const { store, emit } = setup()
127+
128+
emit(wsMessage({ progress: 0.5, status: 'running' }))
129+
emit(wsMessage({ progress: 0.9, status: 'running' }))
130+
131+
expect(store.exportList).toHaveLength(1)
132+
expect(store.exportList[0].progress).toBe(0.9)
133+
})
134+
135+
it('ignores updates for an export already completed and downloaded', async () => {
136+
const { store, emit } = setup()
137+
138+
emit(wsMessage({ status: 'completed' }))
139+
await Promise.resolve()
140+
const triggeredCalls = getExportDownloadUrl.mock.calls.length
141+
142+
// A late 'running' message must not revive a completed+downloaded export
143+
emit(wsMessage({ status: 'running', progress: 0.1 }))
144+
145+
expect(store.exportList[0].status).toBe('completed')
146+
expect(getExportDownloadUrl).toHaveBeenCalledTimes(triggeredCalls)
147+
})
148+
149+
it('falls back to the prior export name when a message omits it', async () => {
150+
const { store, emit } = setup()
151+
152+
emit(wsMessage({ status: 'running', progress: 0.4 }))
153+
emit(
154+
wsMessage({ status: 'running', export_name: undefined, progress: 0.6 })
155+
)
156+
157+
expect(store.exportList[0].exportName).toBe('export.zip')
158+
})
159+
160+
it('falls back to a blank export name when no message has named it', () => {
161+
const { store, emit } = setup()
162+
163+
emit(wsMessage({ export_name: undefined, status: 'running' }))
164+
165+
expect(store.exportList[0].exportName).toBe('')
166+
})
167+
168+
it('triggers a download for a named export and clears prior errors', async () => {
169+
const { store, emit } = setup()
170+
emit(wsMessage({ status: 'running' }))
171+
const [exp] = store.exportList
172+
173+
await store.triggerDownload(exp)
174+
175+
expect(getExportDownloadUrl).toHaveBeenCalledWith('export.zip')
176+
expect(exp.downloadTriggered).toBe(true)
177+
expect(exp.downloadError).toBeUndefined()
178+
})
179+
180+
it('does not re-trigger a download unless forced', async () => {
181+
const { store, emit } = setup()
182+
emit(wsMessage({ status: 'running' }))
183+
const [exp] = store.exportList
184+
exp.downloadTriggered = true
185+
186+
await store.triggerDownload(exp)
187+
expect(getExportDownloadUrl).not.toHaveBeenCalled()
188+
189+
await store.triggerDownload(exp, true)
190+
expect(getExportDownloadUrl).toHaveBeenCalledTimes(1)
191+
})
192+
193+
it('records a download error and surfaces a toast on failure', async () => {
194+
getExportDownloadUrl.mockRejectedValueOnce(new Error('network down'))
195+
const { store, emit } = setup()
196+
emit(wsMessage({ status: 'running' }))
197+
const [exp] = store.exportList
198+
199+
await store.triggerDownload(exp)
200+
201+
expect(exp.downloadError).toBe('network down')
202+
expect(exp.downloadTriggered).toBe(false)
203+
expect(toastAdd).toHaveBeenCalledWith(
204+
expect.objectContaining({ severity: 'error' })
205+
)
206+
})
207+
208+
it('records a string download error', async () => {
209+
getExportDownloadUrl.mockRejectedValueOnce('offline')
210+
const { store, emit } = setup()
211+
emit(wsMessage({ status: 'running' }))
212+
const [exp] = store.exportList
213+
214+
await store.triggerDownload(exp)
215+
216+
expect(exp.downloadError).toBe('offline')
217+
})
218+
219+
it('clears finished exports while keeping active ones', () => {
220+
const { store, emit } = setup()
221+
emit(wsMessage({ task_id: 'a', status: 'running' }))
222+
emit(wsMessage({ task_id: 'b', status: 'failed', export_name: 'b.zip' }))
223+
224+
store.clearFinishedExports()
225+
226+
expect(store.exportList.map((e) => e.taskId)).toEqual(['a'])
227+
})
228+
229+
it('does not poll when no active export is stale', async () => {
230+
const { emit, runPoll } = setup()
231+
emit(wsMessage({ status: 'running' }))
232+
233+
await runPoll()
234+
235+
expect(getTask).not.toHaveBeenCalled()
236+
})
237+
238+
it('reconciles a stale export from the task service result', async () => {
239+
const { store, emit, runPoll } = setup()
240+
emit(wsMessage({ status: 'running' }))
241+
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
242+
getTask.mockResolvedValue({
243+
status: 'completed',
244+
result: { export_name: 'reconciled.zip', assets_total: 10 }
245+
})
246+
247+
await runPoll()
248+
249+
expect(getTask).toHaveBeenCalledWith('task-1')
250+
expect(store.exportList[0].status).toBe('completed')
251+
expect(store.exportList[0].exportName).toBe('reconciled.zip')
252+
})
253+
254+
it('leaves a stale export active when the task is still running', async () => {
255+
const { store, emit, runPoll } = setup()
256+
emit(wsMessage({ status: 'running' }))
257+
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
258+
getTask.mockResolvedValue({ status: 'running' })
259+
260+
await runPoll()
261+
262+
expect(store.exportList[0].status).toBe('running')
263+
})
264+
265+
it('reconciles a stale failed export using existing counters', async () => {
266+
const { store, emit, runPoll } = setup()
267+
emit(
268+
wsMessage({
269+
assets_attempted: 4,
270+
assets_failed: 1,
271+
status: 'running'
272+
})
273+
)
274+
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
275+
getTask.mockResolvedValue({
276+
status: 'failed',
277+
result: { error: 'failed in result' }
278+
})
279+
280+
await runPoll()
281+
282+
expect(store.exportList[0]).toMatchObject({
283+
assetsAttempted: 4,
284+
assetsFailed: 1,
285+
error: 'failed in result',
286+
status: 'failed'
287+
})
288+
})
289+
290+
it('leaves a stale export untouched when the task lookup fails', async () => {
291+
const { store, emit, runPoll } = setup()
292+
emit(wsMessage({ status: 'running' }))
293+
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
294+
getTask.mockRejectedValue(new Error('task not found'))
295+
296+
await runPoll()
297+
298+
expect(store.exportList[0].status).toBe('running')
299+
})
300+
})

0 commit comments

Comments
 (0)