Files
dsh-web/web/src/Instances.test.tsx
T
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00

82 lines
3.7 KiB
TypeScript

import { expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {
const slots = state.slots, i = state.cursor++;
if (!(i in slots)) slots[i] = value;
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
},
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => state.resources[state.resourceCursor++],
useAction: () => ({busy: false, feedback: null}),
}));
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
const profile = {
data: {content: 'name: original'}, loading: false,
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
};
const parentSlots: any[] = [], detailSlots: any[] = [];
function renderParent() {
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [instances, usage];
return Instances();
}
function renderDetail() {
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
expect(detail).toBeDefined();
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
return detail.type(detail.props);
}
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
let tree = renderDetail();
expect(editor(tree).props.value).toBe('name: original');
expect(save(tree).props.disabled).toBe(true);
editor(tree).props.onChange('name: unsaved draft');
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.refresh();
expect(instances.refresh).toHaveBeenCalledOnce();
expect(usage.refresh).toHaveBeenCalledOnce();
expect(profile.refresh).not.toHaveBeenCalled();
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.onRestored();
expect(profile.refresh).toHaveBeenCalledOnce();
expect(instances.refresh).toHaveBeenCalledTimes(2);
expect(usage.refresh).toHaveBeenCalledTimes(2);
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: restored');
expect(save(tree).props.disabled).toBe(true);
});