156 lines
5.5 KiB
TypeScript
156 lines
5.5 KiB
TypeScript
// Guards the download flows the chips depend on: one POST per click, the chip
|
|
// reading downloaded once the list reports the model, a resume keeping the
|
|
// sidecars it started with, a cancel dropping the whole download, and a reload
|
|
// restoring the paused state of an unfinished one.
|
|
|
|
import { ModelAuxSidecar, ModelDraftSidecar, ServerModelStatus, ServerRole } from '$lib/enums';
|
|
import { ModelsService } from '$lib/services/models.service';
|
|
import { modelsStore } from '$lib/stores';
|
|
import { serverStore } from '$lib/stores/server.svelte';
|
|
import type { ApiModelDataEntry, ApiModelsListResponse } from '$lib/types/api';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const REPO = 'org/model-GGUF';
|
|
const TAG = `${REPO}:Q4_K_M`;
|
|
const DRAFTS = [ModelAuxSidecar.MMPROJ, ModelDraftSidecar.DFLASH];
|
|
|
|
/** The listing entry a router reports once a tag is downloaded. */
|
|
function entry(id: string, sidecars: string[] = [], status = ServerModelStatus.DOWNLOADED) {
|
|
return {
|
|
created: 0,
|
|
id,
|
|
in_cache: true,
|
|
object: 'model',
|
|
owned_by: 'llamacpp',
|
|
path: `/models/${id}`,
|
|
sidecars,
|
|
status: { value: status }
|
|
} as unknown as ApiModelDataEntry;
|
|
}
|
|
|
|
/** The download endpoints the store talks to; the feed is not live in these tests. */
|
|
function stubEndpoints() {
|
|
const post = vi.spyOn(ModelsService, 'downloadModel').mockResolvedValue({ success: true });
|
|
const cancel = vi.spyOn(ModelsService, 'cancelDownload').mockResolvedValue({ success: true });
|
|
const unload = vi.spyOn(ModelsService, 'unload').mockResolvedValue({ success: true });
|
|
|
|
vi.spyOn(ModelsService, 'watchModelEvents').mockResolvedValue(undefined);
|
|
vi.spyOn(ModelsService, 'list').mockResolvedValue({
|
|
data: [],
|
|
models: [],
|
|
object: 'list'
|
|
} as unknown as ApiModelsListResponse);
|
|
|
|
return { cancel, post, unload };
|
|
}
|
|
|
|
describe('model download flows', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
serverStore.role = ServerRole.ROUTER;
|
|
modelsStore.routerModels = [];
|
|
// the store is a singleton: drop what an earlier case tracked
|
|
modelsStore.status.unsubscribe();
|
|
});
|
|
|
|
it('posts once for two quick clicks on the same chip', async () => {
|
|
const { post } = stubEndpoints();
|
|
const first = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
const second = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
|
|
// the chip reads in flight before the server answers, so the second click
|
|
// finds it busy instead of posting again
|
|
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
|
|
|
await Promise.all([first, second]);
|
|
|
|
expect(post).toHaveBeenCalledTimes(1);
|
|
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
|
});
|
|
|
|
it('reads the chip as downloaded once the list reports the model', async () => {
|
|
stubEndpoints();
|
|
|
|
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
|
|
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(false);
|
|
|
|
// the feed's reload lands as a listing the tag now appears in
|
|
modelsStore.routerModels = [entry(TAG, DRAFTS)];
|
|
|
|
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(true);
|
|
// the sidecar option of that model reads the draft off the same entry
|
|
expect(modelsStore.status.isModelDownloaded(`${TAG}-dflash`)).toBe(true);
|
|
});
|
|
|
|
it('does not pause a download whose POST is still on its way', async () => {
|
|
const { post, unload } = stubEndpoints();
|
|
|
|
let release: () => void = () => {};
|
|
|
|
post.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
release = () => resolve({ success: true });
|
|
})
|
|
);
|
|
|
|
const started = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
|
|
// the chip reads as downloading before the server answers, so a second click
|
|
// must not stop a model that never started
|
|
await modelsStore.status.pauseDownload(TAG);
|
|
|
|
expect(unload).not.toHaveBeenCalled();
|
|
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
|
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
|
|
|
release();
|
|
await started;
|
|
});
|
|
|
|
it('resumes with the sidecars the download started with', async () => {
|
|
const { post } = stubEndpoints();
|
|
|
|
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
await modelsStore.status.pauseDownload(TAG);
|
|
|
|
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
|
|
|
// the resume a chip sends names no kinds of its own
|
|
await modelsStore.status.downloadModel(TAG);
|
|
|
|
expect(post).toHaveBeenLastCalledWith(TAG, DRAFTS);
|
|
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
|
});
|
|
|
|
it('cancels the model and its sidecars as one download', async () => {
|
|
const { cancel } = stubEndpoints();
|
|
|
|
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
|
await modelsStore.status.cancelDownload(TAG);
|
|
|
|
// no sidecar of its own is asked for: the model goes as one unit
|
|
expect(cancel).toHaveBeenCalledWith(TAG, null);
|
|
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(false);
|
|
expect(modelsStore.status.getDownloadEntries()).toEqual([]);
|
|
});
|
|
|
|
it('restores the paused state of an unfinished download after a reload', async () => {
|
|
const { post } = stubEndpoints();
|
|
|
|
modelsStore.routerModels = [entry(TAG, DRAFTS, ServerModelStatus.INCOMPLETE)];
|
|
modelsStore.status.syncPausedDownloads();
|
|
|
|
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
|
expect(modelsStore.status.getDownloadEntries()).toEqual([
|
|
{ isPaused: true, progress: null, repoWithTag: TAG }
|
|
]);
|
|
|
|
// and its resume keeps the kinds the incomplete listing reports
|
|
await modelsStore.status.downloadModel(TAG);
|
|
|
|
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
|
});
|
|
});
|