fix(pr-68): harden model disable flows
Co-authored-by: grassroadsZ <44087648+grassroadsZ@users.noreply.github.com>
This commit is contained in:
+69
-40
@@ -129,6 +129,7 @@ export default function Accounts() {
|
|||||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastRebindTargetRef = useRef<any | null>(null);
|
const lastRebindTargetRef = useRef<any | null>(null);
|
||||||
|
const modelModalRequestSeqRef = useRef(0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
if (rebindTarget) lastRebindTargetRef.current = rebindTarget;
|
||||||
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
const activeRebindTarget = rebindTarget || lastRebindTargetRef.current;
|
||||||
@@ -405,26 +406,67 @@ export default function Accounts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModelModal = async (account: any) => {
|
const applyLoadedModelModal = (account: any, result: any) => {
|
||||||
setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' }));
|
const models = Array.isArray(result?.models) ? result.models : [];
|
||||||
|
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
|
||||||
|
setModelModal(s => ({
|
||||||
|
...s,
|
||||||
|
loading: false,
|
||||||
|
models,
|
||||||
|
pendingDisabled: disabledSet,
|
||||||
|
siteName: result?.siteName || account.site?.name || s.siteName,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadModelModalModels = async (
|
||||||
|
account: any,
|
||||||
|
options: {
|
||||||
|
refreshUpstream?: boolean;
|
||||||
|
resetBeforeLoad?: boolean;
|
||||||
|
closeOnError?: boolean;
|
||||||
|
successMessage?: string | null;
|
||||||
|
errorMessage?: string;
|
||||||
|
} = {},
|
||||||
|
) => {
|
||||||
|
const requestId = ++modelModalRequestSeqRef.current;
|
||||||
|
setModelModal(s => ({
|
||||||
|
...s,
|
||||||
|
open: true,
|
||||||
|
account,
|
||||||
|
loading: true,
|
||||||
|
...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set<string>(), siteName: '' } : {}),
|
||||||
|
}));
|
||||||
try {
|
try {
|
||||||
|
if (options.refreshUpstream) {
|
||||||
|
await api.checkModels(account.id);
|
||||||
|
}
|
||||||
const result = await api.getAccountModels(account.id);
|
const result = await api.getAccountModels(account.id);
|
||||||
const models = Array.isArray(result?.models) ? result.models : [];
|
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||||
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
|
applyLoadedModelModal(account, result);
|
||||||
setModelModal(s => ({
|
if (options.successMessage) {
|
||||||
...s,
|
toast.success(options.successMessage);
|
||||||
loading: false,
|
}
|
||||||
models,
|
|
||||||
pendingDisabled: disabledSet,
|
|
||||||
siteName: result?.siteName || account.site?.name || '',
|
|
||||||
}));
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '加载模型列表失败');
|
if (modelModalRequestSeqRef.current !== requestId) return;
|
||||||
setModelModal(s => ({ ...s, open: false, loading: false }));
|
toast.error(e.message || options.errorMessage || '加载模型列表失败');
|
||||||
|
setModelModal(s => (
|
||||||
|
options.closeOnError
|
||||||
|
? { ...s, open: false, account: null, loading: false }
|
||||||
|
: { ...s, loading: false }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openModelModal = async (account: any) => {
|
||||||
|
await loadModelModalModels(account, {
|
||||||
|
resetBeforeLoad: true,
|
||||||
|
closeOnError: true,
|
||||||
|
errorMessage: '加载模型列表失败',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const closeModelModal = () => {
|
const closeModelModal = () => {
|
||||||
|
modelModalRequestSeqRef.current += 1;
|
||||||
setModelModal(s => ({ ...s, open: false, account: null }));
|
setModelModal(s => ({ ...s, open: false, account: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -443,11 +485,12 @@ export default function Accounts() {
|
|||||||
setModelModal(s => ({ ...s, saving: true }));
|
setModelModal(s => ({ ...s, saving: true }));
|
||||||
try {
|
try {
|
||||||
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
|
await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled));
|
||||||
// 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行)
|
|
||||||
try {
|
try {
|
||||||
await api.rebuildRoutes(false, false);
|
await api.rebuildRoutes(false, false);
|
||||||
} catch { /* 路由重建失败不阻塞保存流程 */ }
|
toast.success('模型禁用设置已保存,路由已重建');
|
||||||
toast.success('模型禁用设置已保存,路由已重建');
|
} catch {
|
||||||
|
toast.error('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
}
|
||||||
closeModelModal();
|
closeModelModal();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '保存失败');
|
toast.error(e.message || '保存失败');
|
||||||
@@ -1875,18 +1918,11 @@ export default function Accounts() {
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!modelModal.account) return;
|
if (!modelModal.account) return;
|
||||||
setModelModal(s => ({ ...s, loading: true }));
|
await loadModelModalModels(modelModal.account, {
|
||||||
try {
|
refreshUpstream: true,
|
||||||
await api.checkModels(modelModal.account.id);
|
successMessage: '模型列表已刷新',
|
||||||
const result = await api.getAccountModels(modelModal.account.id);
|
errorMessage: '刷新失败',
|
||||||
const models = Array.isArray(result?.models) ? result.models : [];
|
});
|
||||||
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
|
|
||||||
setModelModal(s => ({ ...s, loading: false, models, pendingDisabled: disabledSet, siteName: result?.siteName || s.siteName }));
|
|
||||||
toast.success('模型列表已刷新');
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(e.message || '刷新失败');
|
|
||||||
setModelModal(s => ({ ...s, loading: false }));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
className="btn btn-soft-primary"
|
className="btn btn-soft-primary"
|
||||||
@@ -1930,18 +1966,11 @@ export default function Accounts() {
|
|||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!modelModal.account) return;
|
if (!modelModal.account) return;
|
||||||
setModelModal(s => ({ ...s, loading: true }));
|
await loadModelModalModels(modelModal.account, {
|
||||||
try {
|
refreshUpstream: true,
|
||||||
await api.checkModels(modelModal.account.id);
|
successMessage: '模型列表已刷新',
|
||||||
const result = await api.getAccountModels(modelModal.account.id);
|
errorMessage: '刷新失败',
|
||||||
const models = Array.isArray(result?.models) ? result.models : [];
|
});
|
||||||
const disabledSet = new Set<string>(models.filter((m: any) => m.disabled).map((m: any) => m.name as string));
|
|
||||||
setModelModal(s => ({ ...s, loading: false, models, pendingDisabled: disabledSet, siteName: result?.siteName || s.siteName }));
|
|
||||||
toast.success('模型列表已刷新');
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(e.message || '刷新失败');
|
|
||||||
setModelModal(s => ({ ...s, loading: false }));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
disabled={modelModal.saving}
|
disabled={modelModal.saving}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
|
|||||||
@@ -232,7 +232,12 @@ export default function Sites() {
|
|||||||
setDisabledModelsSaving(true);
|
setDisabledModelsSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
|
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
|
||||||
toast.success('禁用模型列表已保存');
|
try {
|
||||||
|
await api.rebuildRoutes(false, false);
|
||||||
|
toast.success('禁用模型列表已保存,路由已重建');
|
||||||
|
} catch {
|
||||||
|
toast.error('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || '保存禁用模型失败');
|
toast.error(e.message || '保存禁用模型失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const { apiMock, toastMock } = vi.hoisted(() => ({
|
|||||||
getAccounts: vi.fn(),
|
getAccounts: vi.fn(),
|
||||||
getSites: vi.fn(),
|
getSites: vi.fn(),
|
||||||
updateAccount: vi.fn(),
|
updateAccount: vi.fn(),
|
||||||
|
updateSiteDisabledModels: vi.fn(),
|
||||||
|
rebuildRoutes: vi.fn(),
|
||||||
refreshAccountHealth: vi.fn(),
|
refreshAccountHealth: vi.fn(),
|
||||||
checkModels: vi.fn(),
|
checkModels: vi.fn(),
|
||||||
getAccountModels: vi.fn(),
|
getAccountModels: vi.fn(),
|
||||||
@@ -45,6 +47,16 @@ async function flushMicrotasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('Accounts edit panel', () => {
|
describe('Accounts edit panel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -62,6 +74,8 @@ describe('Accounts edit panel', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
apiMock.updateAccount.mockResolvedValue({ success: true });
|
apiMock.updateAccount.mockResolvedValue({ success: true });
|
||||||
|
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||||
|
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||||
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
apiMock.refreshAccountHealth.mockResolvedValue({ success: true });
|
||||||
apiMock.getAccountModels.mockResolvedValue({
|
apiMock.getAccountModels.mockResolvedValue({
|
||||||
siteId: 1,
|
siteId: 1,
|
||||||
@@ -159,6 +173,147 @@ describe('Accounts edit panel', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores stale model modal loads after switching accounts', async () => {
|
||||||
|
const firstLoad = deferred<any>();
|
||||||
|
const secondLoad = deferred<any>();
|
||||||
|
apiMock.getAccounts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
siteId: 1,
|
||||||
|
username: 'alpha',
|
||||||
|
accessToken: 'session-alpha',
|
||||||
|
status: 'active',
|
||||||
|
site: { id: 1, name: 'Site A', status: 'active', platform: 'new-api' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
siteId: 2,
|
||||||
|
username: 'beta',
|
||||||
|
accessToken: 'session-beta',
|
||||||
|
status: 'active',
|
||||||
|
site: { id: 2, name: 'Site B', status: 'active', platform: 'new-api' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 1, name: 'Site A', platform: 'new-api', status: 'active' },
|
||||||
|
{ id: 2, name: 'Site B', platform: 'new-api', status: 'active' },
|
||||||
|
]);
|
||||||
|
apiMock.getAccountModels.mockImplementation((accountId: number) => {
|
||||||
|
if (accountId === 1) return firstLoad.promise;
|
||||||
|
if (accountId === 2) return secondLoad.promise;
|
||||||
|
return Promise.resolve({ siteId: accountId, siteName: 'Unknown', models: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const modelButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-info')
|
||||||
|
&& collectText(node).trim() === '模型'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void modelButtons[0]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
void modelButtons[1]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
secondLoad.resolve({
|
||||||
|
siteId: 2,
|
||||||
|
siteName: 'Site B',
|
||||||
|
models: [{ name: 'model-b', latencyMs: 66, disabled: false }],
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
firstLoad.resolve({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Site A',
|
||||||
|
models: [{ name: 'model-a', latencyMs: 33, disabled: false }],
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root.toJSON());
|
||||||
|
expect(rendered).toContain('模型管理 · Site B');
|
||||||
|
expect(rendered).toContain('model-b');
|
||||||
|
expect(rendered).not.toContain('model-a');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports route rebuild failure without claiming success', async () => {
|
||||||
|
apiMock.getAccountModels.mockResolvedValue({
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Site A',
|
||||||
|
models: [{ name: 'gpt-4', latencyMs: 120, disabled: false }],
|
||||||
|
totalCount: 1,
|
||||||
|
disabledCount: 0,
|
||||||
|
});
|
||||||
|
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const modelButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-info')
|
||||||
|
&& collectText(node).trim() === '模型'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await modelButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await saveButtons[saveButtons.length - 1]!.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||||
|
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||||
|
expect(toastMock.error).toHaveBeenCalledWith('模型禁用设置已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
expect(toastMock.success).not.toHaveBeenCalledWith('模型禁用设置已保存,路由已重建');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Sites from './Sites.js';
|
||||||
|
|
||||||
|
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getSites: vi.fn(),
|
||||||
|
getSiteDisabledModels: vi.fn(),
|
||||||
|
updateSiteDisabledModels: vi.fn(),
|
||||||
|
rebuildRoutes: vi.fn(),
|
||||||
|
},
|
||||||
|
toastMock: {
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
toast: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/Toast.js', () => ({
|
||||||
|
ToastProvider: ({ children }: { children: ReactNode }) => children,
|
||||||
|
useToast: () => toastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sites disabled models save', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Demo Site',
|
||||||
|
url: 'https://example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSiteDisabledModels.mockResolvedValue({ models: [] });
|
||||||
|
apiMock.updateSiteDisabledModels.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports route rebuild failure after saving disabled models from sites editor', async () => {
|
||||||
|
apiMock.rebuildRoutes.mockRejectedValue(new Error('rebuild failed'));
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Sites />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const editButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '编辑'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('保存禁用列表')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateSiteDisabledModels).toHaveBeenCalledWith(1, []);
|
||||||
|
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||||
|
expect(toastMock.error).toHaveBeenCalledWith('禁用模型列表已保存,但路由重建失败,请手动刷新路由');
|
||||||
|
expect(toastMock.success).not.toHaveBeenCalledWith('禁用模型列表已保存,路由已重建');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user