修复泥点消耗刷新不及时 (#138)
Project CI / Repository checks (push) Successful in 50s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled

refactor:
- 主站复用game agent的zustand store

fix:
- 修复旧请求覆盖问题
- 修复账号切换问题

- 主站原本已经每 4 秒轮询 external generation 任务状态, 在这里补充触发余额刷新的时机

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/138
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #138.
This commit is contained in:
2026-08-08 10:25:02 +08:00
committed by 段舒康
parent 44de26d204
commit fc0dcb0782
36 changed files with 4160 additions and 491 deletions
@@ -474,6 +474,423 @@ describe('ImageCanvasTaskSidebarView', () => {
}
});
it('refreshes the wallet while an external task is running and after a failed refund settles', async () => {
vi.useFakeTimers();
try {
const onExternalTasksCompleted = vi.fn();
const onExternalTaskWalletMayHaveChanged = vi.fn();
let activeRequestCount = 0;
const runningTask = createExternalTask({
jobId: 'wallet-running-task',
requestLabel: '长耗时图片生成',
status: 'running',
priceMudPoints: 20,
});
const failedTask = createExternalTask({
...runningTask,
status: 'failed',
progress: 0,
phaseDetail: '生成失败。',
error: '生成失败,泥点已退回。',
completedAt: new Date().toISOString(),
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: activeRequestCount === 1 ? 1 : 0,
unacknowledgedTerminalCount: activeRequestCount === 1 ? 0 : 1,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount === 1 ? [runningTask] : [],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: activeRequestCount > 1 ? 1 : 0,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount > 1 ? [failedTask] : [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTasksCompleted={onExternalTasksCompleted}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
expect(onExternalTasksCompleted).not.toHaveBeenCalled();
expect(refreshCanvasMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('keeps wallet polling global when the active external task belongs to another project', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
let activeRequestCount = 0;
const otherProjectTask = createExternalTask({
jobId: 'other-project-wallet-task',
sourceEntityId: 'project-other',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: activeRequestCount === 1 ? 1 : 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: activeRequestCount === 1 ? [otherProjectTask] : [],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText('发光猫咪主视觉')).toBeNull();
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it('retries a failed bootstrap and starts wallet polling from the recovered active task', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'bootstrap-recovered-task',
status: 'running',
});
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(
options: Parameters<typeof listExternalGenerationTasks>[0] = {},
): ReturnType<typeof listExternalGenerationTasks> => {
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('temporary bootstrap failure'));
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [runningTask],
});
}
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: activeRequestCount,
},
tasks: [],
});
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(4000);
await Promise.resolve();
});
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalledTimes(2);
expect(activeRequestCount).toBe(3);
} finally {
vi.useRealTimers();
}
});
it('keeps a successful active task and wallet polling when only completed tasks fail', async () => {
vi.useFakeTimers();
try {
const onExternalTaskWalletMayHaveChanged = vi.fn();
const runningTask = createExternalTask({
jobId: 'active-while-completed-fails',
requestLabel: '仍在生成的任务',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
if (options.statuses?.includes('running')) {
return Promise.resolve({
overview: {
pendingCount: 0,
runningCount: 1,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: [runningTask],
});
}
return Promise.reject(new Error('completed tasks unavailable'));
},
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
onExternalTaskWalletMayHaveChanged={
onExternalTaskWalletMayHaveChanged
}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(onExternalTaskWalletMayHaveChanged).toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(screen.getByText('仍在生成的任务')).toBeTruthy();
expect(
onExternalTaskWalletMayHaveChanged.mock.calls.length,
).toBeGreaterThan(1);
} finally {
vi.useRealTimers();
}
});
it('stops bootstrap retries after the bounded retry schedule is exhausted', async () => {
vi.useFakeTimers();
try {
listExternalGenerationTasksMock.mockRejectedValue(
new Error('task list unavailable'),
);
render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(8);
} finally {
vi.useRealTimers();
}
});
it('clears stale tasks after a refreshed bootstrap exhausts its retries', async () => {
vi.useFakeTimers();
try {
const staleTask = createExternalTask({
jobId: 'stale-running-task',
requestLabel: '旧生成任务',
status: 'running',
});
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
Promise.resolve({
overview: {
pendingCount: 0,
runningCount: options.statuses?.includes('running') ? 1 : 0,
unacknowledgedTerminalCount: 0,
updatedAtMicros: 1,
},
tasks: options.statuses?.includes('running') ? [staleTask] : [],
}),
);
const { rerender } = render(
<ImageCanvasTaskSidebarView
refreshKey={0}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('旧生成任务')).toBeTruthy();
listExternalGenerationTasksMock.mockRejectedValue(
new Error('task list unavailable'),
);
rerender(
<ImageCanvasTaskSidebarView
refreshKey={1}
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
await Promise.resolve();
});
expect(screen.queryByText('旧生成任务')).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
it('aborts every bootstrap attempt when one branch fails and after retry unmounts', async () => {
vi.useFakeTimers();
try {
const requestSignals: AbortSignal[] = [];
let activeRequestCount = 0;
listExternalGenerationTasksMock.mockImplementation(
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) => {
const signal = options.signal;
if (!signal) {
return Promise.reject(new Error('missing abort signal'));
}
requestSignals.push(signal);
if (options.statuses?.includes('running')) {
activeRequestCount += 1;
if (activeRequestCount === 1) {
return Promise.reject(new Error('active bootstrap failed'));
}
}
return new Promise<
Awaited<ReturnType<typeof listExternalGenerationTasks>>
>((_, reject) => {
signal.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
);
});
},
);
const { unmount } = render(
<ImageCanvasTaskSidebarView
open
onToggleOpen={vi.fn()}
onFocusExternalTask={vi.fn()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(requestSignals).toHaveLength(2);
expect(requestSignals[0]).toBe(requestSignals[1]);
expect(requestSignals[0]!.aborted).toBe(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
await Promise.resolve();
});
expect(requestSignals).toHaveLength(4);
expect(requestSignals[2]).toBe(requestSignals[3]);
expect(requestSignals[2]!.aborted).toBe(false);
unmount();
expect(requestSignals[2]!.aborted).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('loads more completed tasks while scrolling and keeps the request capped', async () => {
const completedTasks = Array.from({ length: 120 }, (_, index) => {
const completedAt = new Date(Date.now() - index * 1000).toISOString();